diff --git a/src/core/project-scanner/DocumentationGenerator.ts b/src/core/project-scanner/DocumentationGenerator.ts new file mode 100644 index 00000000000..1fe3f717bdc --- /dev/null +++ b/src/core/project-scanner/DocumentationGenerator.ts @@ -0,0 +1,342 @@ +import { ProjectInfo, Technology, ConfigFile, DirectoryInfo } from "./ProjectScanner" + +export class DocumentationGenerator { + async generateDocumentation(projectInfo: ProjectInfo): Promise { + const sections: string[] = [] + + // Header + sections.push(`# ${projectInfo.name}`) + sections.push("") + sections.push(projectInfo.description) + sections.push("") + + // Project Overview + sections.push("## Project Overview") + sections.push("") + sections.push( + `This document provides a comprehensive overview of the **${projectInfo.name}** project structure, technologies, and setup instructions. This file is automatically generated by Roo Code's \`/init\` command to help maintain consistent project documentation.`, + ) + sections.push("") + + // Table of Contents + sections.push("## Table of Contents") + sections.push("") + sections.push("- [Technologies](#technologies)") + sections.push("- [Project Structure](#project-structure)") + sections.push("- [Configuration Files](#configuration-files)") + sections.push("- [Dependencies](#dependencies)") + sections.push("- [Available Scripts](#available-scripts)") + sections.push("- [Development Setup](#development-setup)") + sections.push("- [Architecture Patterns](#architecture-patterns)") + if (projectInfo.gitInfo.hasGit) { + sections.push("- [Git Information](#git-information)") + } + sections.push("") + + // Technologies + sections.push("## Technologies") + sections.push("") + sections.push("This project uses the following technologies:") + sections.push("") + + const techByType = this.groupTechnologiesByType(projectInfo.technologies) + for (const [type, techs] of Object.entries(techByType)) { + sections.push(`### ${this.formatTechType(type)}`) + sections.push("") + for (const tech of techs) { + const version = tech.version ? ` (${tech.version})` : "" + const config = tech.configFile ? ` - Config: \`${tech.configFile}\`` : "" + sections.push(`- **${tech.name}**${version}${config}`) + } + sections.push("") + } + + // Project Structure + sections.push("## Project Structure") + sections.push("") + sections.push("```") + sections.push(this.generateTreeStructure(projectInfo.structure.directories)) + sections.push("```") + sections.push("") + + // File statistics + sections.push("### File Statistics") + sections.push("") + sections.push(`- Total files: ${projectInfo.structure.fileCount}`) + sections.push(`- File types:`) + const sortedFileTypes = Object.entries(projectInfo.structure.fileTypes) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) + for (const [ext, count] of sortedFileTypes) { + const extName = ext || "(no extension)" + sections.push(` - ${extName}: ${count} files`) + } + sections.push("") + + // Configuration Files + sections.push("## Configuration Files") + sections.push("") + sections.push("The following configuration files are present in the project:") + sections.push("") + + const configByType = this.groupConfigsByType(projectInfo.configFiles) + for (const [type, configs] of Object.entries(configByType)) { + sections.push(`### ${this.formatConfigType(type)}`) + sections.push("") + for (const config of configs) { + sections.push(`- \`${config.path}\``) + } + sections.push("") + } + + // Dependencies + if ( + Object.keys(projectInfo.dependencies.production).length > 0 || + Object.keys(projectInfo.dependencies.development).length > 0 + ) { + sections.push("## Dependencies") + sections.push("") + + if (Object.keys(projectInfo.dependencies.production).length > 0) { + sections.push("### Production Dependencies") + sections.push("") + sections.push("```json") + sections.push(JSON.stringify(projectInfo.dependencies.production, null, 2)) + sections.push("```") + sections.push("") + } + + if (Object.keys(projectInfo.dependencies.development).length > 0) { + sections.push("### Development Dependencies") + sections.push("") + sections.push("```json") + sections.push(JSON.stringify(projectInfo.dependencies.development, null, 2)) + sections.push("```") + sections.push("") + } + } + + // Available Scripts + if (Object.keys(projectInfo.scripts).length > 0) { + sections.push("## Available Scripts") + sections.push("") + sections.push("The following scripts are available:") + sections.push("") + + for (const [name, command] of Object.entries(projectInfo.scripts)) { + if (command === "Makefile target") { + sections.push(`- \`${name}\` - Makefile target`) + } else { + sections.push(`- \`npm run ${name}\` - ${command}`) + } + } + sections.push("") + } + + // Development Setup + sections.push("## Development Setup") + sections.push("") + sections.push("To set up this project for development:") + sections.push("") + + const setupSteps = this.generateSetupSteps(projectInfo) + setupSteps.forEach((step, index) => { + sections.push(`${index + 1}. ${step}`) + }) + sections.push("") + + // Architecture Patterns + if ( + projectInfo.patterns.architecture || + projectInfo.patterns.testingFramework || + projectInfo.patterns.buildTool || + projectInfo.patterns.cicd + ) { + sections.push("## Architecture Patterns") + sections.push("") + + if (projectInfo.patterns.architecture) { + sections.push(`- **Architecture**: ${projectInfo.patterns.architecture}`) + } + if (projectInfo.patterns.testingFramework) { + sections.push(`- **Testing Framework**: ${projectInfo.patterns.testingFramework}`) + } + if (projectInfo.patterns.buildTool) { + sections.push(`- **Build Tool**: ${projectInfo.patterns.buildTool}`) + } + if (projectInfo.patterns.packageManager) { + sections.push(`- **Package Manager**: ${projectInfo.patterns.packageManager}`) + } + if (projectInfo.patterns.cicd && projectInfo.patterns.cicd.length > 0) { + sections.push(`- **CI/CD**: ${projectInfo.patterns.cicd.join(", ")}`) + } + sections.push("") + } + + // Git Information + if (projectInfo.gitInfo.hasGit) { + sections.push("## Git Information") + sections.push("") + if (projectInfo.gitInfo.branch) { + sections.push(`- **Current Branch**: ${projectInfo.gitInfo.branch}`) + } + if (projectInfo.gitInfo.remote) { + sections.push(`- **Remote Repository**: ${projectInfo.gitInfo.remote}`) + } + sections.push("") + } + + // Footer + sections.push("---") + sections.push("") + sections.push(`*This documentation was automatically generated by Roo Code on ${new Date().toISOString()}*`) + sections.push("") + + return sections.join("\n") + } + + private groupTechnologiesByType(technologies: Technology[]): Record { + const grouped: Record = {} + for (const tech of technologies) { + if (!grouped[tech.type]) { + grouped[tech.type] = [] + } + grouped[tech.type].push(tech) + } + return grouped + } + + private groupConfigsByType(configs: ConfigFile[]): Record { + const grouped: Record = {} + for (const config of configs) { + if (!grouped[config.type]) { + grouped[config.type] = [] + } + grouped[config.type].push(config) + } + return grouped + } + + private formatTechType(type: string): string { + const typeMap: Record = { + language: "Languages", + framework: "Frameworks", + tool: "Tools", + database: "Databases", + service: "Services", + } + return typeMap[type] || type + } + + private formatConfigType(type: string): string { + const typeMap: Record = { + npm: "NPM Configuration", + typescript: "TypeScript Configuration", + eslint: "ESLint Configuration", + prettier: "Prettier Configuration", + webpack: "Webpack Configuration", + vite: "Vite Configuration", + jest: "Jest Configuration", + vitest: "Vitest Configuration", + git: "Git Configuration", + docker: "Docker Configuration", + environment: "Environment Configuration", + python: "Python Configuration", + go: "Go Configuration", + rust: "Rust Configuration", + } + return typeMap[type] || type + } + + private generateTreeStructure(directories: DirectoryInfo[], prefix: string = ""): string { + const lines: string[] = [] + + // Add root indicator + if (prefix === "") { + lines.push(".") + } + + // Sort directories by name + const sorted = directories.sort((a, b) => a.name.localeCompare(b.name)) + + for (let i = 0; i < sorted.length; i++) { + const dir = sorted[i] + const isLast = i === sorted.length - 1 + const connector = isLast ? "└── " : "├── " + const extension = isLast ? " " : "│ " + + lines.push(`${prefix}${connector}${dir.name}/`) + + // Add file count if directory has files + if (dir.fileCount > 0) { + lines.push(`${prefix}${extension} (${dir.fileCount} files)`) + } + } + + return lines.join("\n") + } + + private generateSetupSteps(projectInfo: ProjectInfo): string[] { + const steps: string[] = [] + + // Clone repository if git remote exists + if (projectInfo.gitInfo.remote) { + steps.push(`Clone the repository: \`git clone ${projectInfo.gitInfo.remote}\``) + steps.push(`Navigate to the project directory: \`cd ${projectInfo.name}\``) + } + + // Install dependencies based on package manager + if (projectInfo.patterns.packageManager) { + const pm = projectInfo.patterns.packageManager + if (pm === "npm") { + steps.push("Install dependencies: `npm install`") + } else if (pm === "yarn") { + steps.push("Install dependencies: `yarn install`") + } else if (pm === "pnpm") { + steps.push("Install dependencies: `pnpm install`") + } + } else if (projectInfo.technologies.some((t) => t.name === "Node.js")) { + steps.push("Install dependencies: `npm install`") + } + + // Python setup + if (projectInfo.technologies.some((t) => t.name === "Python")) { + steps.push("Create a virtual environment: `python -m venv venv`") + steps.push("Activate the virtual environment:") + steps.push(" - On Windows: `venv\\Scripts\\activate`") + steps.push(" - On macOS/Linux: `source venv/bin/activate`") + if (projectInfo.configFiles.some((c) => c.name === "requirements.txt")) { + steps.push("Install Python dependencies: `pip install -r requirements.txt`") + } + } + + // Go setup + if (projectInfo.technologies.some((t) => t.name === "Go")) { + steps.push("Download Go dependencies: `go mod download`") + } + + // Rust setup + if (projectInfo.technologies.some((t) => t.name === "Rust")) { + steps.push("Build the project: `cargo build`") + } + + // Environment setup + if (projectInfo.configFiles.some((c) => c.name === ".env.example")) { + steps.push("Copy the example environment file: `cp .env.example .env`") + steps.push("Update the `.env` file with your local configuration") + } + + // Add common development scripts + if (projectInfo.scripts["dev"]) { + steps.push("Start the development server: `npm run dev`") + } else if (projectInfo.scripts["start"]) { + steps.push("Start the development server: `npm run start`") + } + + if (projectInfo.scripts["test"]) { + steps.push("Run tests: `npm run test`") + } + + return steps + } +} diff --git a/src/core/project-scanner/ProjectScanner.ts b/src/core/project-scanner/ProjectScanner.ts new file mode 100644 index 00000000000..371d30e8a75 --- /dev/null +++ b/src/core/project-scanner/ProjectScanner.ts @@ -0,0 +1,432 @@ +import * as path from "path" +import * as fs from "fs/promises" +import { listFiles } from "../../services/glob/list-files" +import { RooIgnoreController } from "../ignore/RooIgnoreController" + +export interface ProjectInfo { + name: string + description: string + rootPath: string + structure: ProjectStructure + technologies: Technology[] + configFiles: ConfigFile[] + dependencies: Dependencies + scripts: Scripts + gitInfo: GitInfo + patterns: ProjectPatterns +} + +export interface ProjectStructure { + directories: DirectoryInfo[] + fileCount: number + totalSize: number + fileTypes: Record +} + +export interface DirectoryInfo { + name: string + path: string + fileCount: number + subdirectories: string[] +} + +export interface Technology { + name: string + version?: string + configFile?: string + type: "language" | "framework" | "tool" | "database" | "service" +} + +export interface ConfigFile { + name: string + path: string + type: string + content?: any +} + +export interface Dependencies { + production: Record + development: Record + peer?: Record +} + +export interface Scripts { + [key: string]: string +} + +export interface GitInfo { + hasGit: boolean + branch?: string + remote?: string + lastCommit?: string +} + +export interface ProjectPatterns { + architecture?: string + testingFramework?: string + buildTool?: string + packageManager?: string + cicd?: string[] +} + +export class ProjectScanner { + constructor( + private readonly rootPath: string, + private readonly rooIgnoreController?: RooIgnoreController, + ) {} + + async scanProject(): Promise { + const [files, _] = await listFiles(this.rootPath, true, 10000) + + // Filter files through rooIgnoreController if available + const filteredFiles = this.rooIgnoreController + ? files.filter((file) => { + // Check if the rooIgnoreController would filter this path + const filtered = this.rooIgnoreController!.filterPaths([file]) + return filtered.includes(file) + }) + : files + + const projectInfo: ProjectInfo = { + name: await this.detectProjectName(), + description: await this.detectProjectDescription(), + rootPath: this.rootPath, + structure: await this.analyzeStructure(filteredFiles), + technologies: await this.detectTechnologies(filteredFiles), + configFiles: await this.findConfigFiles(filteredFiles), + dependencies: await this.analyzeDependencies(), + scripts: await this.analyzeScripts(), + gitInfo: await this.analyzeGitInfo(), + patterns: await this.detectPatterns(filteredFiles), + } + + return projectInfo + } + + private async detectProjectName(): Promise { + // Try to get name from package.json + try { + const packageJsonPath = path.join(this.rootPath, "package.json") + const content = await fs.readFile(packageJsonPath, "utf-8") + const packageJson = JSON.parse(content) + if (packageJson.name) return packageJson.name + } catch {} + + // Try to get name from pyproject.toml + try { + const pyprojectPath = path.join(this.rootPath, "pyproject.toml") + const content = await fs.readFile(pyprojectPath, "utf-8") + const match = content.match(/name\s*=\s*"([^"]+)"/) + if (match) return match[1] + } catch {} + + // Default to directory name + return path.basename(this.rootPath) + } + + private async detectProjectDescription(): Promise { + // Try to get description from package.json + try { + const packageJsonPath = path.join(this.rootPath, "package.json") + const content = await fs.readFile(packageJsonPath, "utf-8") + const packageJson = JSON.parse(content) + if (packageJson.description) return packageJson.description + } catch {} + + // Try to get description from README + try { + const readmePath = path.join(this.rootPath, "README.md") + const content = await fs.readFile(readmePath, "utf-8") + const lines = content.split("\n") + // Get first non-header, non-empty line + for (const line of lines) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("![")) { + return trimmed.substring(0, 200) + } + } + } catch {} + + return "No description available" + } + + private async analyzeStructure(files: string[]): Promise { + const directories = new Map() + const fileTypes: Record = {} + let totalSize = 0 + + for (const file of files) { + if (file.endsWith("/")) { + // It's a directory + const dirPath = file.slice(0, -1) + const dirName = path.basename(dirPath) + const parentDir = path.dirname(dirPath) + + if (!directories.has(dirPath)) { + directories.set(dirPath, { + name: dirName, + path: dirPath, + fileCount: 0, + subdirectories: [], + }) + } + + if (parentDir !== "." && directories.has(parentDir)) { + directories.get(parentDir)!.subdirectories.push(dirName) + } + } else { + // It's a file + const ext = path.extname(file).toLowerCase() + fileTypes[ext] = (fileTypes[ext] || 0) + 1 + + const dir = path.dirname(file) + if (directories.has(dir)) { + directories.get(dir)!.fileCount++ + } + } + } + + // Get top-level directories + const topLevelDirs = Array.from(directories.values()) + .filter((dir) => path.dirname(dir.path) === ".") + .sort((a, b) => a.name.localeCompare(b.name)) + + return { + directories: topLevelDirs, + fileCount: files.filter((f) => !f.endsWith("/")).length, + totalSize, + fileTypes, + } + } + + private async detectTechnologies(files: string[]): Promise { + const technologies: Technology[] = [] + const fileSet = new Set(files) + + // Node.js / JavaScript + if (fileSet.has("package.json")) { + technologies.push({ name: "Node.js", type: "language", configFile: "package.json" }) + + // Check for specific frameworks + try { + const content = await fs.readFile(path.join(this.rootPath, "package.json"), "utf-8") + const pkg = JSON.parse(content) + const deps = { ...pkg.dependencies, ...pkg.devDependencies } + + if (deps.react) technologies.push({ name: "React", version: deps.react, type: "framework" }) + if (deps.vue) technologies.push({ name: "Vue", version: deps.vue, type: "framework" }) + if (deps.angular) technologies.push({ name: "Angular", version: deps.angular, type: "framework" }) + if (deps.express) technologies.push({ name: "Express", version: deps.express, type: "framework" }) + if (deps.next) technologies.push({ name: "Next.js", version: deps.next, type: "framework" }) + if (deps.typescript) + technologies.push({ name: "TypeScript", version: deps.typescript, type: "language" }) + if (deps.jest || deps.vitest || deps.mocha) { + const testFramework = deps.jest ? "Jest" : deps.vitest ? "Vitest" : "Mocha" + technologies.push({ name: testFramework, type: "tool" }) + } + } catch {} + } + + // Python + if (fileSet.has("requirements.txt") || fileSet.has("pyproject.toml") || fileSet.has("setup.py")) { + technologies.push({ name: "Python", type: "language" }) + + // Check for frameworks + try { + const reqPath = path.join(this.rootPath, "requirements.txt") + const content = await fs.readFile(reqPath, "utf-8") + if (content.includes("django")) technologies.push({ name: "Django", type: "framework" }) + if (content.includes("flask")) technologies.push({ name: "Flask", type: "framework" }) + if (content.includes("fastapi")) technologies.push({ name: "FastAPI", type: "framework" }) + } catch {} + } + + // Go + if (fileSet.has("go.mod")) { + technologies.push({ name: "Go", type: "language", configFile: "go.mod" }) + } + + // Rust + if (fileSet.has("Cargo.toml")) { + technologies.push({ name: "Rust", type: "language", configFile: "Cargo.toml" }) + } + + // Docker + if (fileSet.has("Dockerfile") || fileSet.has("docker-compose.yml")) { + technologies.push({ name: "Docker", type: "tool" }) + } + + return technologies + } + + private async findConfigFiles(files: string[]): Promise { + const configFiles: ConfigFile[] = [] + const configPatterns = [ + { pattern: "package.json", type: "npm" }, + { pattern: "tsconfig.json", type: "typescript" }, + { pattern: ".eslintrc", type: "eslint" }, + { pattern: ".prettierrc", type: "prettier" }, + { pattern: "webpack.config.js", type: "webpack" }, + { pattern: "vite.config", type: "vite" }, + { pattern: "jest.config", type: "jest" }, + { pattern: "vitest.config", type: "vitest" }, + { pattern: ".gitignore", type: "git" }, + { pattern: "Dockerfile", type: "docker" }, + { pattern: "docker-compose", type: "docker" }, + { pattern: ".env.example", type: "environment" }, + { pattern: "requirements.txt", type: "python" }, + { pattern: "pyproject.toml", type: "python" }, + { pattern: "go.mod", type: "go" }, + { pattern: "Cargo.toml", type: "rust" }, + ] + + for (const file of files) { + const basename = path.basename(file) + for (const { pattern, type } of configPatterns) { + if (basename.includes(pattern)) { + configFiles.push({ + name: basename, + path: file, + type, + }) + } + } + } + + return configFiles + } + + private async analyzeDependencies(): Promise { + const dependencies: Dependencies = { + production: {}, + development: {}, + } + + // Try to read package.json + try { + const packageJsonPath = path.join(this.rootPath, "package.json") + const content = await fs.readFile(packageJsonPath, "utf-8") + const pkg = JSON.parse(content) + + dependencies.production = pkg.dependencies || {} + dependencies.development = pkg.devDependencies || {} + dependencies.peer = pkg.peerDependencies + } catch {} + + return dependencies + } + + private async analyzeScripts(): Promise { + const scripts: Scripts = {} + + // Try to read package.json scripts + try { + const packageJsonPath = path.join(this.rootPath, "package.json") + const content = await fs.readFile(packageJsonPath, "utf-8") + const pkg = JSON.parse(content) + + if (pkg.scripts) { + Object.assign(scripts, pkg.scripts) + } + } catch {} + + // Try to read Makefile + try { + const makefilePath = path.join(this.rootPath, "Makefile") + const content = await fs.readFile(makefilePath, "utf-8") + const lines = content.split("\n") + + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+):/) + if (match) { + scripts[`make ${match[1]}`] = "Makefile target" + } + } + } catch {} + + return scripts + } + + private async analyzeGitInfo(): Promise { + const gitInfo: GitInfo = { + hasGit: false, + } + + try { + // Check if .git directory exists + await fs.access(path.join(this.rootPath, ".git")) + gitInfo.hasGit = true + + // Try to read current branch + try { + const headContent = await fs.readFile(path.join(this.rootPath, ".git", "HEAD"), "utf-8") + const match = headContent.match(/ref: refs\/heads\/(.+)/) + if (match) { + gitInfo.branch = match[1].trim() + } + } catch {} + + // Try to read remote + try { + const configContent = await fs.readFile(path.join(this.rootPath, ".git", "config"), "utf-8") + const remoteMatch = configContent.match(/url = (.+)/) + if (remoteMatch) { + gitInfo.remote = remoteMatch[1].trim() + } + } catch {} + } catch {} + + return gitInfo + } + + private async detectPatterns(files: string[]): Promise { + const patterns: ProjectPatterns = {} + const fileSet = new Set(files) + + // Detect architecture + if (fileSet.has("src/") && (fileSet.has("src/components/") || fileSet.has("src/pages/"))) { + patterns.architecture = "Component-based" + } else if (fileSet.has("app/") && fileSet.has("app/models/")) { + patterns.architecture = "MVC" + } else if (fileSet.has("src/") && fileSet.has("src/domain/")) { + patterns.architecture = "Domain-driven" + } + + // Detect testing framework + if (fileSet.has("jest.config.js") || fileSet.has("jest.config.ts")) { + patterns.testingFramework = "Jest" + } else if (fileSet.has("vitest.config.js") || fileSet.has("vitest.config.ts")) { + patterns.testingFramework = "Vitest" + } else if (fileSet.has(".mocharc.json")) { + patterns.testingFramework = "Mocha" + } + + // Detect build tool + if (fileSet.has("webpack.config.js")) { + patterns.buildTool = "Webpack" + } else if (fileSet.has("vite.config.js") || fileSet.has("vite.config.ts")) { + patterns.buildTool = "Vite" + } else if (fileSet.has("rollup.config.js")) { + patterns.buildTool = "Rollup" + } + + // Detect package manager + if (fileSet.has("package-lock.json")) { + patterns.packageManager = "npm" + } else if (fileSet.has("yarn.lock")) { + patterns.packageManager = "yarn" + } else if (fileSet.has("pnpm-lock.yaml")) { + patterns.packageManager = "pnpm" + } + + // Detect CI/CD + const cicd: string[] = [] + if (fileSet.has(".github/workflows/")) cicd.push("GitHub Actions") + if (fileSet.has(".gitlab-ci.yml")) cicd.push("GitLab CI") + if (fileSet.has(".circleci/")) cicd.push("CircleCI") + if (fileSet.has("Jenkinsfile")) cicd.push("Jenkins") + if (cicd.length > 0) patterns.cicd = cicd + + return patterns + } +} diff --git a/src/core/project-scanner/__tests__/DocumentationGenerator.test.ts b/src/core/project-scanner/__tests__/DocumentationGenerator.test.ts new file mode 100644 index 00000000000..8501cd6e9f7 --- /dev/null +++ b/src/core/project-scanner/__tests__/DocumentationGenerator.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest" +import { DocumentationGenerator } from "../DocumentationGenerator" +import { ProjectInfo } from "../ProjectScanner" + +describe("DocumentationGenerator", () => { + const mockProjectInfo: ProjectInfo = { + name: "test-project", + description: "A test project for unit testing", + rootPath: "/test/project", + structure: { + directories: [ + { + name: "src", + path: "src", + fileCount: 5, + subdirectories: ["components", "utils"], + }, + { + name: "tests", + path: "tests", + fileCount: 3, + subdirectories: [], + }, + ], + fileCount: 15, + totalSize: 0, + fileTypes: { + ".ts": 8, + ".tsx": 4, + ".json": 2, + ".md": 1, + }, + }, + technologies: [ + { name: "Node.js", type: "language", configFile: "package.json" }, + { name: "TypeScript", type: "language", version: "^5.0.0" }, + { name: "React", type: "framework", version: "^18.0.0" }, + { name: "Jest", type: "tool" }, + ], + configFiles: [ + { name: "package.json", path: "package.json", type: "npm" }, + { name: "tsconfig.json", path: "tsconfig.json", type: "typescript" }, + { name: ".gitignore", path: ".gitignore", type: "git" }, + ], + dependencies: { + production: { + react: "^18.0.0", + "react-dom": "^18.0.0", + }, + development: { + typescript: "^5.0.0", + jest: "^29.0.0", + }, + }, + scripts: { + start: "react-scripts start", + build: "react-scripts build", + test: "jest", + }, + gitInfo: { + hasGit: true, + branch: "main", + remote: "https://github.com/user/test-project.git", + }, + patterns: { + architecture: "Component-based", + testingFramework: "Jest", + buildTool: "Webpack", + packageManager: "npm", + cicd: ["GitHub Actions"], + }, + } + + it("should generate documentation with all sections", async () => { + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(mockProjectInfo) + + // Check that the documentation contains expected sections + expect(documentation).toContain("# test-project") + expect(documentation).toContain("A test project for unit testing") + expect(documentation).toContain("## Technologies") + expect(documentation).toContain("## Project Structure") + expect(documentation).toContain("## Configuration Files") + expect(documentation).toContain("## Dependencies") + expect(documentation).toContain("## Available Scripts") + expect(documentation).toContain("## Development Setup") + expect(documentation).toContain("## Architecture Patterns") + expect(documentation).toContain("## Git Information") + }) + + it("should include technology details", async () => { + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(mockProjectInfo) + + expect(documentation).toContain("**Node.js**") + expect(documentation).toContain("**TypeScript** (^5.0.0)") + expect(documentation).toContain("**React** (^18.0.0)") + expect(documentation).toContain("**Jest**") + }) + + it("should include file statistics", async () => { + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(mockProjectInfo) + + expect(documentation).toContain("Total files: 15") + expect(documentation).toContain(".ts: 8 files") + expect(documentation).toContain(".tsx: 4 files") + }) + + it("should include setup instructions", async () => { + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(mockProjectInfo) + + expect(documentation).toContain("Clone the repository: `git clone https://github.com/user/test-project.git`") + expect(documentation).toContain("Install dependencies: `npm install`") + expect(documentation).toContain("Start the development server: `npm run start`") + expect(documentation).toContain("Run tests: `npm run test`") + }) + + it("should include timestamp", async () => { + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(mockProjectInfo) + + expect(documentation).toContain("This documentation was automatically generated by Roo Code on") + }) +}) diff --git a/src/core/project-scanner/__tests__/ProjectScanner.test.ts b/src/core/project-scanner/__tests__/ProjectScanner.test.ts new file mode 100644 index 00000000000..0faff3835c2 --- /dev/null +++ b/src/core/project-scanner/__tests__/ProjectScanner.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { ProjectScanner } from "../ProjectScanner" + +// Mock the listFiles function +vi.mock("../../../services/glob/list-files", () => ({ + listFiles: vi.fn().mockResolvedValue([["package.json", "src/", "src/index.ts", "README.md", ".gitignore"], false]), +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), +})) + +describe("ProjectScanner", () => { + const mockRootPath = "/test/project" + let scanner: ProjectScanner + + beforeEach(() => { + vi.clearAllMocks() + scanner = new ProjectScanner(mockRootPath) + }) + + describe("scanProject", () => { + it("should detect project name from package.json", async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join(mockRootPath, "package.json")) { + return JSON.stringify({ + name: "test-project", + description: "A test project", + dependencies: { + react: "^18.0.0", + typescript: "^5.0.0", + }, + }) + } + throw new Error("File not found") + }) + + const result = await scanner.scanProject() + + expect(result.name).toBe("test-project") + expect(result.description).toBe("A test project") + expect(result.rootPath).toBe(mockRootPath) + }) + + it("should detect technologies from package.json", async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join(mockRootPath, "package.json")) { + return JSON.stringify({ + name: "test-project", + dependencies: { + react: "^18.0.0", + express: "^4.18.0", + }, + devDependencies: { + typescript: "^5.0.0", + jest: "^29.0.0", + }, + }) + } + throw new Error("File not found") + }) + + const result = await scanner.scanProject() + + const techNames = result.technologies.map((t) => t.name) + expect(techNames).toContain("Node.js") + expect(techNames).toContain("React") + expect(techNames).toContain("Express") + expect(techNames).toContain("TypeScript") + expect(techNames).toContain("Jest") + }) + + it("should analyze project structure", async () => { + vi.mocked(fs.readFile).mockImplementation(async () => { + throw new Error("File not found") + }) + + const result = await scanner.scanProject() + + expect(result.structure.fileCount).toBe(4) // Excluding directories + expect(result.structure.fileTypes[".json"]).toBe(1) + expect(result.structure.fileTypes[".ts"]).toBe(1) + expect(result.structure.fileTypes[".md"]).toBe(1) + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 95d12f66aa1..d89fc6fc084 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1,5 +1,6 @@ import * as path from "path" import * as vscode from "vscode" +import * as fs from "fs/promises" import os from "os" import crypto from "crypto" import EventEmitter from "events" @@ -745,6 +746,12 @@ export class Task extends EventEmitter { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() + // Check if the task is the /init command + if (task?.trim() === "/init") { + await this.handleInitCommand() + return + } + await this.say("text", task, images) this.isInitialized = true @@ -761,6 +768,60 @@ export class Task extends EventEmitter { ]) } + private async handleInitCommand(): Promise { + this.isInitialized = true + + try { + // Notify user that we're starting the project scan + await this.say("text", "🔍 Scanning project structure and analyzing codebase...") + + // Import the project scanner module + const { ProjectScanner } = await import("../project-scanner/ProjectScanner") + const { DocumentationGenerator } = await import("../project-scanner/DocumentationGenerator") + + // Create scanner instance + const scanner = new ProjectScanner(this.cwd, this.rooIgnoreController) + + // Scan the project + await this.say("text", "📊 Analyzing project structure...") + const projectInfo = await scanner.scanProject() + + // Generate documentation + await this.say("text", "📝 Generating ROO.md documentation...") + const generator = new DocumentationGenerator() + const documentation = await generator.generateDocumentation(projectInfo) + + // Write ROO.md file using the file system + const rooMdPath = path.join(this.cwd, "ROO.md") + await this.say("text", `✍️ Writing documentation to ${rooMdPath}...`) + + // Write the file directly + await fs.writeFile(rooMdPath, documentation, "utf-8") + + // Provide summary to user + await this.say( + "text", + `✅ Project initialization complete! + +I've created a ROO.md file with comprehensive documentation about your project: +- Project structure and organization +- Detected technologies and frameworks +- Key configuration files +- Development setup instructions +- Important patterns and conventions + +The ROO.md file will help me better understand your project context in future conversations.`, + ) + + // Mark task as complete by completing the task loop + this.abort = true + } catch (error) { + await this.say("error", `Failed to initialize project: ${error.message}`) + console.error("Error in handleInitCommand:", error) + this.abort = true + } + } + public async resumePausedTask(lastMessage: string) { // Release this Cline instance from paused state. this.isPaused = false