From 9136f3351d7798cda9b013c2246c2ee7488e013b Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 13:39:29 -0800 Subject: [PATCH] feat: codebase mapper with CODEBASE.md generation (#190) - Add scan_project Rust command that walks the directory tree and detects project type - Add CODEBASE as a managed ProjectFile type alongside PROJECT/REQUIREMENTS/ROADMAP/STATE - Add mapCodebase() store function that scans and sends an analysis prompt to Claude - Add CODEBASE tab to ProjectContextPanel with Map/Remap buttons and auto-reload on idle - Update PROJECT_CONTEXT_SYSTEM_ADDENDUM to include CODEBASE.md reference --- src-tauri/src/commands.rs | 154 ++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/ProjectContextPanel.svelte | 166 ++++++++++++++---- src/lib/stores/projectContext.test.ts | 113 +++++++++++- src/lib/stores/projectContext.ts | 66 ++++++- 5 files changed, 459 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index acddc96..4e8896d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2337,6 +2337,160 @@ pub async fn get_mcp_server_details(name: String) -> Result { } } +// ==================== Codebase Mapper ==================== + +/// Directories to skip when scanning (always ignored regardless of .gitignore) +const SCAN_SKIP_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + ".next", + "dist", + "build", + "out", + "__pycache__", + ".cache", + ".pytest_cache", + "vendor", + ".idea", + ".vscode", + "coverage", + ".nyc_output", + "venv", + ".venv", + "env", + ".tox", +]; + +/// Files that indicate the project type +const PROJECT_MARKERS: &[(&str, &str)] = &[ + ("Cargo.toml", "Rust"), + ("package.json", "Node.js"), + ("pyproject.toml", "Python"), + ("requirements.txt", "Python"), + ("go.mod", "Go"), + ("pom.xml", "Java (Maven)"), + ("build.gradle", "Java (Gradle)"), + ("Gemfile", "Ruby"), + ("composer.json", "PHP"), + ("*.csproj", "C#/.NET"), + ("CMakeLists.txt", "C/C++ (CMake)"), + ("Makefile", "C/C++"), +]; + +#[derive(Debug, Serialize)] +pub struct ProjectScan { + pub working_dir: String, + pub file_tree: String, + pub detected_type: String, + pub key_files: Vec, +} + +/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels. +fn build_file_tree( + dir: &std::path::Path, + prefix: &str, + depth: usize, + max_depth: usize, + lines: &mut Vec, +) { + if depth > max_depth { + lines.push(format!("{}...", prefix)); + return; + } + + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + let mut items: Vec = entries + .filter_map(|e| e.ok()) + .collect(); + items.sort_by_key(|e| { + let name = e.file_name().to_string_lossy().to_lowercase(); + // Sort: hidden last, directories first + let is_hidden = name.starts_with('.'); + let is_dir = e.path().is_dir(); + (is_hidden, !is_dir, name) + }); + + let count = items.len(); + for (i, entry) in items.iter().enumerate() { + let name = entry.file_name().to_string_lossy().to_string(); + let is_last = i == count - 1; + let connector = if is_last { "└── " } else { "├── " }; + let child_prefix = if is_last { + format!("{} ", prefix) + } else { + format!("{}│ ", prefix) + }; + + let path = entry.path(); + if path.is_dir() { + if SCAN_SKIP_DIRS.contains(&name.as_str()) { + lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name)); + continue; + } + lines.push(format!("{}{}{}/", prefix, connector, name)); + build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines); + } else { + lines.push(format!("{}{}{}", prefix, connector, name)); + } + } +} + +#[tauri::command] +pub async fn scan_project(working_dir: String) -> Result { + let dir_path = std::path::Path::new(&working_dir); + + if !dir_path.exists() { + return Err(format!("Directory does not exist: {}", working_dir)); + } + if !dir_path.is_dir() { + return Err(format!("Path is not a directory: {}", working_dir)); + } + + // Detect project type by checking for marker files + let mut detected_type = "Unknown".to_string(); + let mut key_files: Vec = Vec::new(); + + for (marker, project_type) in PROJECT_MARKERS { + let marker_path = dir_path.join(marker); + if marker_path.exists() { + if detected_type == "Unknown" { + detected_type = project_type.to_string(); + } + key_files.push(marker.to_string()); + } + } + + // Also collect other notable root-level files + let notable_root_files = &[ + "README.md", "CLAUDE.md", "LICENSE", ".env.example", + "docker-compose.yml", "Dockerfile", ".github", + "tsconfig.json", "vitest.config.ts", "eslint.config.js", + "check-all.sh", "tauri.conf.json", + ]; + for file in notable_root_files { + let file_path = dir_path.join(file); + if file_path.exists() && !key_files.contains(&file.to_string()) { + key_files.push(file.to_string()); + } + } + + // Build file tree (max 4 levels deep) + let mut lines: Vec = vec![format!("{}/", working_dir)]; + build_file_tree(dir_path, "", 0, 4, &mut lines); + let file_tree = lines.join("\n"); + + Ok(ProjectScan { + working_dir, + file_tree, + detected_type, + key_files, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0d8ae5..6f9ba8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -220,6 +220,7 @@ pub fn run() { save_draft, delete_draft, delete_all_drafts, + scan_project, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/ProjectContextPanel.svelte b/src/lib/components/ProjectContextPanel.svelte index 1a807ee..dc0e8f7 100644 --- a/src/lib/components/ProjectContextPanel.svelte +++ b/src/lib/components/ProjectContextPanel.svelte @@ -1,10 +1,13 @@
Project Context - {#if $isLoading[$activeFile]} + {#if $activeFile === "CODEBASE"} + {#if $isMappingCodebase} + Mapping codebase... + {:else if $isLoading[$activeFile]} + Loading... + {:else if fileExists($activeFile)} + + ✓ File exists + + {:else} + + ✗ Not generated + + {/if} + {:else if $isLoading[$activeFile]} Loading... {:else if fileExists($activeFile)}
- {#each PROJECT_FILES as file (file)} + {#each ALL_FILES as file (file)} +
+ {:else if $activeFile === "CODEBASE" && $isMappingCodebase} + +
+
⚙️
+

Mapping Codebase...

+

+ Claude is analysing the project and writing CODEBASE.md. This will auto-reload when complete. +

+
+ {:else} + + {/if}
@@ -157,29 +234,50 @@ {workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}
- - - + {#if $activeFile === "CODEBASE"} + + + {:else} + + + + {/if}
diff --git a/src/lib/stores/projectContext.test.ts b/src/lib/stores/projectContext.test.ts index 76a69b9..95902e6 100644 --- a/src/lib/stores/projectContext.test.ts +++ b/src/lib/stores/projectContext.test.ts @@ -9,24 +9,30 @@ import { PROJECT_CONTEXT_SYSTEM_ADDENDUM, injectTextStore, type ProjectFile, + type ProjectScan, } from "./projectContext"; describe("PROJECT_FILE_NAMES", () => { - it("maps all four project file types", () => { + it("maps all five project file types", () => { expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md"); expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md"); expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md"); expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md"); + expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md"); }); }); describe("PROJECT_TEMPLATES", () => { - const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"]; + const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"]; - it.each(files)("returns a non-empty template for %s", (file) => { + it.each(editableFiles)("returns a non-empty template for %s", (file) => { expect(PROJECT_TEMPLATES[file]).toBeTruthy(); expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0); }); + + it("has an empty string template for CODEBASE (auto-generated)", () => { + expect(PROJECT_TEMPLATES.CODEBASE).toBe(""); + }); }); describe("projectContextStore", () => { @@ -41,6 +47,7 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBeNull(); expect(state.ROADMAP).toBeNull(); expect(state.STATE).toBeNull(); + expect(state.CODEBASE).toBeNull(); }); it("has false isLoading for all files", () => { @@ -49,6 +56,7 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBe(false); expect(state.ROADMAP).toBe(false); expect(state.STATE).toBe(false); + expect(state.CODEBASE).toBe(false); }); it("has false isSaving for all files", () => { @@ -57,18 +65,25 @@ describe("projectContextStore", () => { expect(state.REQUIREMENTS).toBe(false); expect(state.ROADMAP).toBe(false); expect(state.STATE).toBe(false); + expect(state.CODEBASE).toBe(false); }); it("has PROJECT as the default activeFile", () => { expect(get(projectContextStore.activeFile)).toBe("PROJECT"); }); + it("has false isMappingCodebase initially", () => { + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + }); + it("exposes all expected methods", () => { expect(typeof projectContextStore.loadFile).toBe("function"); expect(typeof projectContextStore.saveFile).toBe("function"); expect(typeof projectContextStore.loadAll).toBe("function"); expect(typeof projectContextStore.setActiveFile).toBe("function"); expect(typeof projectContextStore.getTemplate).toBe("function"); + expect(typeof projectContextStore.mapCodebase).toBe("function"); + expect(typeof projectContextStore.finishMapping).toBe("function"); }); }); @@ -203,7 +218,7 @@ describe("projectContextStore", () => { }); describe("loadAll", () => { - it("loads all four files in parallel", async () => { + it("loads all five files in parallel", async () => { setMockInvokeResult("read_file_content", "file content"); await projectContextStore.loadAll("/home/naomi/myproject"); @@ -215,6 +230,7 @@ describe("projectContextStore", () => { expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md"); expect(paths).toContain("/home/naomi/myproject/ROADMAP.md"); expect(paths).toContain("/home/naomi/myproject/STATE.md"); + expect(paths).toContain("/home/naomi/myproject/CODEBASE.md"); }); it("sets all files isLoading to false after completion", async () => { @@ -227,6 +243,7 @@ describe("projectContextStore", () => { expect(loadingState.REQUIREMENTS).toBe(false); expect(loadingState.ROADMAP).toBe(false); expect(loadingState.STATE).toBe(false); + expect(loadingState.CODEBASE).toBe(false); }); }); @@ -257,6 +274,91 @@ describe("projectContextStore", () => { const uniqueTemplates = new Set(templates); expect(uniqueTemplates.size).toBe(files.length); }); + + it("returns empty string for CODEBASE", () => { + expect(projectContextStore.getTemplate("CODEBASE")).toBe(""); + }); + }); + + describe("mapCodebase", () => { + const mockScan: ProjectScan = { + working_dir: "/home/naomi/myproject", + file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json", + detected_type: "Node.js", + key_files: ["package.json"], + }; + + it("calls scan_project with the working directory", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(invoke).toHaveBeenCalledWith("scan_project", { + workingDir: "/home/naomi/myproject", + }); + }); + + it("calls send_prompt with the conversation id and a non-empty prompt", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(invoke).toHaveBeenCalledWith("send_prompt", { + conversationId: "conv-123", + message: expect.stringContaining("CODEBASE.md"), + }); + }); + + it("prompt includes detected project type", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); + const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; + expect(message).toContain("Node.js"); + }); + + it("prompt includes file tree", async () => { + setMockInvokeResult("scan_project", mockScan); + setMockInvokeResult("send_prompt", undefined); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); + const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; + expect(message).toContain("package.json"); + }); + + it("resets isMappingCodebase to false on error", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("scan_project", new Error("Scan failed")); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + consoleSpy.mockRestore(); + }); + + it("logs error when scan_project fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("scan_project", new Error("Scan failed")); + + await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123"); + + expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("finishMapping", () => { + it("sets isMappingCodebase to false", () => { + projectContextStore.finishMapping(); + expect(get(projectContextStore.isMappingCodebase)).toBe(false); + }); }); }); @@ -266,11 +368,12 @@ describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => { expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0); }); - it("mentions all four context file names", () => { + it("mentions all five context file names", () => { expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md"); expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md"); + expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md"); }); }); diff --git a/src/lib/stores/projectContext.ts b/src/lib/stores/projectContext.ts index 6886181..18a5dfb 100644 --- a/src/lib/stores/projectContext.ts +++ b/src/lib/stores/projectContext.ts @@ -1,13 +1,14 @@ import { writable } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; -export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE"; +export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE" | "CODEBASE"; export const PROJECT_FILE_NAMES: Record = { PROJECT: "PROJECT.md", REQUIREMENTS: "REQUIREMENTS.md", ROADMAP: "ROADMAP.md", STATE: "STATE.md", + CODEBASE: "CODEBASE.md", }; export const PROJECT_TEMPLATES: Record = { @@ -51,16 +52,25 @@ export const PROJECT_TEMPLATES: Record = { ## Next Steps `, + CODEBASE: "", }; const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[]; +export interface ProjectScan { + working_dir: string; + file_tree: string; + detected_type: string; + key_files: string[]; +} + function createProjectContextStore() { const contents = writable>({ PROJECT: null, REQUIREMENTS: null, ROADMAP: null, STATE: null, + CODEBASE: null, }); const isLoading = writable>({ @@ -68,6 +78,7 @@ function createProjectContextStore() { REQUIREMENTS: false, ROADMAP: false, STATE: false, + CODEBASE: false, }); const isSaving = writable>({ @@ -75,9 +86,11 @@ function createProjectContextStore() { REQUIREMENTS: false, ROADMAP: false, STATE: false, + CODEBASE: false, }); const activeFile = writable("PROJECT"); + const isMappingCodebase = writable(false); async function loadFile(file: ProjectFile, workingDirectory: string): Promise { isLoading.update((state) => ({ ...state, [file]: true })); @@ -123,19 +136,67 @@ function createProjectContextStore() { return PROJECT_TEMPLATES[file]; } + async function mapCodebase(workingDirectory: string, conversationId: string): Promise { + isMappingCodebase.set(true); + try { + const scan = await invoke("scan_project", { + workingDir: workingDirectory, + }); + + const prompt = buildCodebaseMapPrompt(scan); + await invoke("send_prompt", { conversationId, message: prompt }); + } catch (error) { + console.error("Failed to map codebase:", error); + isMappingCodebase.set(false); + } + } + + function finishMapping(): void { + isMappingCodebase.set(false); + } + return { contents: { subscribe: contents.subscribe }, isLoading: { subscribe: isLoading.subscribe }, isSaving: { subscribe: isSaving.subscribe }, activeFile: { subscribe: activeFile.subscribe }, + isMappingCodebase: { subscribe: isMappingCodebase.subscribe }, loadFile, saveFile, loadAll, setActiveFile, getTemplate, + mapCodebase, + finishMapping, }; } +function buildCodebaseMapPrompt(scan: ProjectScan): string { + const keyFilesSection = + scan.key_files.length > 0 + ? `\n\nKey files detected:\n${scan.key_files.map((f) => `- ${f}`).join("\n")}` + : ""; + + return `Please analyse this codebase and generate a comprehensive \`CODEBASE.md\` file in the working directory (${scan.working_dir}). + +Project type detected: **${scan.detected_type}**${keyFilesSection} + +Directory structure: +\`\`\` +${scan.file_tree} +\`\`\` + +The CODEBASE.md file should include: +1. **Overview** — what the project does and its purpose +2. **Architecture** — key directories, how the code is organised, and the overall structure +3. **Key Components** — the most important files and modules, what they do, and how they interact +4. **Data Flow** — how data moves through the system (if applicable) +5. **Dependencies** — notable external dependencies and why they are used +6. **Development Notes** — anything helpful for a developer new to the codebase + +Write the file concisely but thoroughly. Focus on information that helps a developer understand the codebase quickly. Use the actual file structure above to inform your analysis — read the key files as needed before writing.`; +} + export const projectContextStore = createProjectContextStore(); // Signal store for injecting context into the active InputBar. @@ -152,4 +213,5 @@ The following project context files may exist in your working directory. If they - PROJECT.md — project overview, goals, and architecture - REQUIREMENTS.md — functional and non-functional requirements - ROADMAP.md — current sprint, backlog, and completed work -- STATE.md — current state, known issues, and next steps`; +- STATE.md — current state, known issues, and next steps +- CODEBASE.md — auto-generated codebase map and architecture overview`;