From 912df24f4a064523c1124aef5e8029a8c2ad71c2 Mon Sep 17 00:00:00 2001 From: justtrance-web Date: Tue, 3 Mar 2026 09:07:10 +0300 Subject: [PATCH] feat: automatic project type and framework detection (#293) Add SessionStart hook integration that auto-detects project languages and frameworks by inspecting marker files and dependency manifests. Supports 12 languages (Python, TypeScript, Go, Rust, Ruby, Java, C#, Swift, Kotlin, Elixir, PHP, JavaScript) and 25+ frameworks (Next.js, React, Django, FastAPI, Rails, Laravel, Spring, etc.). Detection output is injected into Claude's context as JSON, enabling context-aware recommendations without loading irrelevant rules. - New: scripts/lib/project-detect.js (cross-platform detection library) - Modified: scripts/hooks/session-start.js (integration) - New: tests/lib/project-detect.test.js (28 tests, all passing) Co-authored-by: Claude --- scripts/hooks/session-start.js | 17 ++ scripts/lib/project-detect.js | 413 ++++++++++++++++++++++++++++++ tests/lib/project-detect.test.js | 418 +++++++++++++++++++++++++++++++ tests/run-all.js | 1 + 4 files changed, 849 insertions(+) create mode 100644 scripts/lib/project-detect.js create mode 100644 tests/lib/project-detect.test.js diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index d101798f..1a044f31 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -20,6 +20,7 @@ const { } = require('../lib/utils'); const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager'); const { listAliases } = require('../lib/session-aliases'); +const { detectProjectType } = require('../lib/project-detect'); async function main() { const sessionsDir = getSessionsDir(); @@ -71,6 +72,22 @@ async function main() { log(getSelectionPrompt()); } + // Detect project type and frameworks (#293) + const projectInfo = detectProjectType(); + if (projectInfo.languages.length > 0 || projectInfo.frameworks.length > 0) { + const parts = []; + if (projectInfo.languages.length > 0) { + parts.push(`languages: ${projectInfo.languages.join(', ')}`); + } + if (projectInfo.frameworks.length > 0) { + parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`); + } + log(`[SessionStart] Project detected — ${parts.join('; ')}`); + output(`Project type: ${JSON.stringify(projectInfo)}`); + } else { + log('[SessionStart] No specific project type detected'); + } + process.exit(0); } diff --git a/scripts/lib/project-detect.js b/scripts/lib/project-detect.js new file mode 100644 index 00000000..9b9bb5b1 --- /dev/null +++ b/scripts/lib/project-detect.js @@ -0,0 +1,413 @@ +/** + * Project type and framework detection + * + * Cross-platform (Windows, macOS, Linux) project type detection + * by inspecting files in the working directory. + * + * Resolves: https://github.com/affaan-m/everything-claude-code/issues/293 + */ + +const fs = require('fs'); +const path = require('path'); + +/** + * Language detection rules. + * Each rule checks for marker files or glob patterns in the project root. + */ +const LANGUAGE_RULES = [ + { + type: 'python', + markers: ['requirements.txt', 'pyproject.toml', 'setup.py', 'setup.cfg', 'Pipfile', 'poetry.lock'], + extensions: ['.py'] + }, + { + type: 'typescript', + markers: ['tsconfig.json', 'tsconfig.build.json'], + extensions: ['.ts', '.tsx'] + }, + { + type: 'javascript', + markers: ['package.json', 'jsconfig.json'], + extensions: ['.js', '.jsx', '.mjs'] + }, + { + type: 'golang', + markers: ['go.mod', 'go.sum'], + extensions: ['.go'] + }, + { + type: 'rust', + markers: ['Cargo.toml', 'Cargo.lock'], + extensions: ['.rs'] + }, + { + type: 'ruby', + markers: ['Gemfile', 'Gemfile.lock', 'Rakefile'], + extensions: ['.rb'] + }, + { + type: 'java', + markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'], + extensions: ['.java'] + }, + { + type: 'csharp', + markers: [], + extensions: ['.cs', '.csproj', '.sln'] + }, + { + type: 'swift', + markers: ['Package.swift'], + extensions: ['.swift'] + }, + { + type: 'kotlin', + markers: [], + extensions: ['.kt', '.kts'] + }, + { + type: 'elixir', + markers: ['mix.exs'], + extensions: ['.ex', '.exs'] + }, + { + type: 'php', + markers: ['composer.json', 'composer.lock'], + extensions: ['.php'] + } +]; + +/** + * Framework detection rules. + * Checked after language detection for more specific identification. + */ +const FRAMEWORK_RULES = [ + // Python frameworks + { framework: 'django', language: 'python', markers: ['manage.py'], packageKeys: ['django'] }, + { framework: 'fastapi', language: 'python', markers: [], packageKeys: ['fastapi'] }, + { framework: 'flask', language: 'python', markers: [], packageKeys: ['flask'] }, + + // JavaScript/TypeScript frameworks + { framework: 'nextjs', language: 'typescript', markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'], packageKeys: ['next'] }, + { framework: 'react', language: 'typescript', markers: [], packageKeys: ['react'] }, + { framework: 'vue', language: 'typescript', markers: ['vue.config.js'], packageKeys: ['vue'] }, + { framework: 'angular', language: 'typescript', markers: ['angular.json'], packageKeys: ['@angular/core'] }, + { framework: 'svelte', language: 'typescript', markers: ['svelte.config.js'], packageKeys: ['svelte'] }, + { framework: 'express', language: 'javascript', markers: [], packageKeys: ['express'] }, + { framework: 'nestjs', language: 'typescript', markers: ['nest-cli.json'], packageKeys: ['@nestjs/core'] }, + { framework: 'remix', language: 'typescript', markers: [], packageKeys: ['@remix-run/node', '@remix-run/react'] }, + { framework: 'astro', language: 'typescript', markers: ['astro.config.mjs', 'astro.config.ts'], packageKeys: ['astro'] }, + { framework: 'nuxt', language: 'typescript', markers: ['nuxt.config.js', 'nuxt.config.ts'], packageKeys: ['nuxt'] }, + { framework: 'electron', language: 'typescript', markers: [], packageKeys: ['electron'] }, + + // Ruby frameworks + { framework: 'rails', language: 'ruby', markers: ['config/routes.rb', 'bin/rails'], packageKeys: [] }, + + // Go frameworks + { framework: 'gin', language: 'golang', markers: [], packageKeys: ['github.com/gin-gonic/gin'] }, + { framework: 'echo', language: 'golang', markers: [], packageKeys: ['github.com/labstack/echo'] }, + + // Rust frameworks + { framework: 'actix', language: 'rust', markers: [], packageKeys: ['actix-web'] }, + { framework: 'axum', language: 'rust', markers: [], packageKeys: ['axum'] }, + + // Java frameworks + { framework: 'spring', language: 'java', markers: [], packageKeys: ['spring-boot', 'org.springframework'] }, + + // PHP frameworks + { framework: 'laravel', language: 'php', markers: ['artisan'], packageKeys: ['laravel/framework'] }, + { framework: 'symfony', language: 'php', markers: ['symfony.lock'], packageKeys: ['symfony/framework-bundle'] }, + + // Elixir frameworks + { framework: 'phoenix', language: 'elixir', markers: [], packageKeys: ['phoenix'] } +]; + +/** + * Check if a file exists relative to the project directory + * @param {string} projectDir - Project root directory + * @param {string} filePath - Relative file path + * @returns {boolean} + */ +function fileExists(projectDir, filePath) { + try { + return fs.existsSync(path.join(projectDir, filePath)); + } catch { + return false; + } +} + +/** + * Check if any file with given extension exists in the project root (non-recursive, top-level only) + * @param {string} projectDir - Project root directory + * @param {string[]} extensions - File extensions to check + * @returns {boolean} + */ +function hasFileWithExtension(projectDir, extensions) { + try { + const entries = fs.readdirSync(projectDir, { withFileTypes: true }); + return entries.some(entry => { + if (!entry.isFile()) return false; + const ext = path.extname(entry.name); + return extensions.includes(ext); + }); + } catch { + return false; + } +} + +/** + * Read and parse package.json dependencies + * @param {string} projectDir - Project root directory + * @returns {string[]} Array of dependency names + */ +function getPackageJsonDeps(projectDir) { + try { + const pkgPath = path.join(projectDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return []; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + return [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}) + ]; + } catch { + return []; + } +} + +/** + * Read requirements.txt or pyproject.toml for Python package names + * @param {string} projectDir - Project root directory + * @returns {string[]} Array of dependency names (lowercase) + */ +function getPythonDeps(projectDir) { + const deps = []; + + // requirements.txt + try { + const reqPath = path.join(projectDir, 'requirements.txt'); + if (fs.existsSync(reqPath)) { + const content = fs.readFileSync(reqPath, 'utf8'); + content.split('\n').forEach(line => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) { + const name = trimmed.split(/[>= { + const name = m.replace(/"/g, '').split(/[>= { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('//')) { + const parts = trimmed.split(/\s+/); + if (parts[0]) deps.push(parts[0]); + } + }); + } + return deps; + } catch { + return []; + } +} + +/** + * Read Cargo.toml for Rust crate dependencies + * @param {string} projectDir - Project root directory + * @returns {string[]} Array of crate names + */ +function getRustDeps(projectDir) { + try { + const cargoPath = path.join(projectDir, 'Cargo.toml'); + if (!fs.existsSync(cargoPath)) return []; + const content = fs.readFileSync(cargoPath, 'utf8'); + const deps = []; + // Match [dependencies] and [dev-dependencies] sections + const sections = content.match(/\[(dev-)?dependencies\]([\s\S]*?)(?=\n\[|$)/g); + if (sections) { + sections.forEach(section => { + section.split('\n').forEach(line => { + const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/); + if (match && !line.startsWith('[')) { + deps.push(match[1]); + } + }); + }); + } + return deps; + } catch { + return []; + } +} + +/** + * Read composer.json for PHP package dependencies + * @param {string} projectDir - Project root directory + * @returns {string[]} Array of package names + */ +function getComposerDeps(projectDir) { + try { + const composerPath = path.join(projectDir, 'composer.json'); + if (!fs.existsSync(composerPath)) return []; + const composer = JSON.parse(fs.readFileSync(composerPath, 'utf8')); + return [ + ...Object.keys(composer.require || {}), + ...Object.keys(composer['require-dev'] || {}) + ]; + } catch { + return []; + } +} + +/** + * Read mix.exs for Elixir dependencies (simple pattern match) + * @param {string} projectDir - Project root directory + * @returns {string[]} Array of dependency atom names + */ +function getElixirDeps(projectDir) { + try { + const mixPath = path.join(projectDir, 'mix.exs'); + if (!fs.existsSync(mixPath)) return []; + const content = fs.readFileSync(mixPath, 'utf8'); + const deps = []; + const matches = content.match(/\{:(\w+)/g); + if (matches) { + matches.forEach(m => deps.push(m.replace('{:', ''))); + } + return deps; + } catch { + return []; + } +} + +/** + * Detect project languages and frameworks + * @param {string} [projectDir] - Project directory (defaults to cwd) + * @returns {{ languages: string[], frameworks: string[], primary: string, projectDir: string }} + */ +function detectProjectType(projectDir) { + projectDir = projectDir || process.cwd(); + const languages = []; + const frameworks = []; + + // Step 1: Detect languages + for (const rule of LANGUAGE_RULES) { + const hasMarker = rule.markers.some(m => fileExists(projectDir, m)); + const hasExt = rule.extensions.length > 0 && hasFileWithExtension(projectDir, rule.extensions); + + if (hasMarker || hasExt) { + languages.push(rule.type); + } + } + + // Deduplicate: if both typescript and javascript detected, keep typescript + if (languages.includes('typescript') && languages.includes('javascript')) { + const idx = languages.indexOf('javascript'); + if (idx !== -1) languages.splice(idx, 1); + } + + // Step 2: Detect frameworks based on markers and dependencies + const npmDeps = getPackageJsonDeps(projectDir); + const pyDeps = getPythonDeps(projectDir); + const goDeps = getGoDeps(projectDir); + const rustDeps = getRustDeps(projectDir); + const composerDeps = getComposerDeps(projectDir); + const elixirDeps = getElixirDeps(projectDir); + + for (const rule of FRAMEWORK_RULES) { + // Check marker files + const hasMarker = rule.markers.some(m => fileExists(projectDir, m)); + + // Check package dependencies + let hasDep = false; + if (rule.packageKeys.length > 0) { + let depList = []; + switch (rule.language) { + case 'python': depList = pyDeps; break; + case 'typescript': + case 'javascript': depList = npmDeps; break; + case 'golang': depList = goDeps; break; + case 'rust': depList = rustDeps; break; + case 'php': depList = composerDeps; break; + case 'elixir': depList = elixirDeps; break; + } + hasDep = rule.packageKeys.some(key => + depList.some(dep => dep.toLowerCase().includes(key.toLowerCase())) + ); + } + + if (hasMarker || hasDep) { + frameworks.push(rule.framework); + } + } + + // Step 3: Determine primary type + let primary = 'unknown'; + if (frameworks.length > 0) { + primary = frameworks[0]; + } else if (languages.length > 0) { + primary = languages[0]; + } + + // Determine if fullstack (both frontend and backend languages) + const frontendSignals = ['react', 'vue', 'angular', 'svelte', 'nextjs', 'nuxt', 'astro', 'remix']; + const backendSignals = ['django', 'fastapi', 'flask', 'express', 'nestjs', 'rails', 'spring', 'laravel', 'phoenix', 'gin', 'echo', 'actix', 'axum']; + const hasFrontend = frameworks.some(f => frontendSignals.includes(f)); + const hasBackend = frameworks.some(f => backendSignals.includes(f)); + + if (hasFrontend && hasBackend) { + primary = 'fullstack'; + } + + return { + languages, + frameworks, + primary, + projectDir + }; +} + +module.exports = { + detectProjectType, + LANGUAGE_RULES, + FRAMEWORK_RULES, + // Exported for testing + getPackageJsonDeps, + getPythonDeps, + getGoDeps, + getRustDeps, + getComposerDeps, + getElixirDeps +}; diff --git a/tests/lib/project-detect.test.js b/tests/lib/project-detect.test.js new file mode 100644 index 00000000..12294060 --- /dev/null +++ b/tests/lib/project-detect.test.js @@ -0,0 +1,418 @@ +/** + * Tests for scripts/lib/project-detect.js + * + * Run with: node tests/lib/project-detect.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { + detectProjectType, + LANGUAGE_RULES, + FRAMEWORK_RULES, + getPackageJsonDeps, + getPythonDeps, + getGoDeps, + getRustDeps, + getComposerDeps, + getElixirDeps +} = require('../../scripts/lib/project-detect'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +// Create a temporary directory for testing +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-test-')); +} + +// Clean up temp directory +function cleanupDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* ignore */ } +} + +// Write a file in the temp directory +function writeTestFile(dir, filePath, content = '') { + const fullPath = path.join(dir, filePath); + const dirName = path.dirname(fullPath); + fs.mkdirSync(dirName, { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +function runTests() { + console.log('\n=== Testing project-detect.js ===\n'); + + let passed = 0; + let failed = 0; + + // Rule definitions tests + console.log('Rule Definitions:'); + + if (test('LANGUAGE_RULES is non-empty array', () => { + assert.ok(Array.isArray(LANGUAGE_RULES)); + assert.ok(LANGUAGE_RULES.length > 0); + })) passed++; else failed++; + + if (test('FRAMEWORK_RULES is non-empty array', () => { + assert.ok(Array.isArray(FRAMEWORK_RULES)); + assert.ok(FRAMEWORK_RULES.length > 0); + })) passed++; else failed++; + + if (test('each language rule has type, markers, and extensions', () => { + for (const rule of LANGUAGE_RULES) { + assert.ok(typeof rule.type === 'string', `Missing type`); + assert.ok(Array.isArray(rule.markers), `Missing markers for ${rule.type}`); + assert.ok(Array.isArray(rule.extensions), `Missing extensions for ${rule.type}`); + } + })) passed++; else failed++; + + if (test('each framework rule has framework, language, markers, packageKeys', () => { + for (const rule of FRAMEWORK_RULES) { + assert.ok(typeof rule.framework === 'string', `Missing framework`); + assert.ok(typeof rule.language === 'string', `Missing language for ${rule.framework}`); + assert.ok(Array.isArray(rule.markers), `Missing markers for ${rule.framework}`); + assert.ok(Array.isArray(rule.packageKeys), `Missing packageKeys for ${rule.framework}`); + } + })) passed++; else failed++; + + // Empty directory detection + console.log('\nEmpty Directory:'); + + if (test('empty directory returns unknown primary', () => { + const dir = createTempDir(); + try { + const result = detectProjectType(dir); + assert.strictEqual(result.primary, 'unknown'); + assert.deepStrictEqual(result.languages, []); + assert.deepStrictEqual(result.frameworks, []); + assert.strictEqual(result.projectDir, dir); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Python detection + console.log('\nPython Detection:'); + + if (test('detects python from requirements.txt', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'requirements.txt', 'flask==3.0.0\nrequests>=2.31'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('python')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects python from pyproject.toml', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'pyproject.toml', '[project]\nname = "test"'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('python')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects flask framework from requirements.txt', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'requirements.txt', 'flask==3.0.0\nrequests>=2.31'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('flask')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects django framework from manage.py', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'manage.py', '#!/usr/bin/env python'); + writeTestFile(dir, 'requirements.txt', 'django>=4.2'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('django')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects fastapi from pyproject.toml dependencies', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'pyproject.toml', '[project]\nname = "test"\ndependencies = [\n "fastapi>=0.100",\n "uvicorn"\n]'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('fastapi')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // TypeScript/JavaScript detection + console.log('\nTypeScript/JavaScript Detection:'); + + if (test('detects typescript from tsconfig.json', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'tsconfig.json', '{}'); + writeTestFile(dir, 'package.json', '{"dependencies":{}}'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('typescript')); + // Should NOT also include javascript when TS is detected + assert.ok(!result.languages.includes('javascript')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects nextjs from next.config.mjs', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'tsconfig.json', '{}'); + writeTestFile(dir, 'next.config.mjs', 'export default {}'); + writeTestFile(dir, 'package.json', '{"dependencies":{"next":"14.0.0","react":"18.0.0"}}'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('nextjs')); + assert.ok(result.frameworks.includes('react')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects react from package.json', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0","react-dom":"18.0.0"}}'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('react')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('detects angular from angular.json', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'angular.json', '{}'); + writeTestFile(dir, 'tsconfig.json', '{}'); + writeTestFile(dir, 'package.json', '{"dependencies":{"@angular/core":"17.0.0"}}'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('angular')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Go detection + console.log('\nGo Detection:'); + + if (test('detects golang from go.mod', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'go.mod', 'module github.com/test/app\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('golang')); + assert.ok(result.frameworks.includes('gin')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Rust detection + console.log('\nRust Detection:'); + + if (test('detects rust from Cargo.toml', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'Cargo.toml', '[package]\nname = "test"\n\n[dependencies]\naxum = "0.7"'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('rust')); + assert.ok(result.frameworks.includes('axum')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Ruby detection + console.log('\nRuby Detection:'); + + if (test('detects ruby and rails', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'Gemfile', 'source "https://rubygems.org"\ngem "rails"'); + writeTestFile(dir, 'config/routes.rb', 'Rails.application.routes.draw do\nend'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('ruby')); + assert.ok(result.frameworks.includes('rails')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // PHP detection + console.log('\nPHP Detection:'); + + if (test('detects php and laravel', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'composer.json', '{"require":{"laravel/framework":"^10.0"}}'); + writeTestFile(dir, 'artisan', '#!/usr/bin/env php'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('php')); + assert.ok(result.frameworks.includes('laravel')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Fullstack detection + console.log('\nFullstack Detection:'); + + if (test('detects fullstack when frontend + backend frameworks present', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0","express":"4.18.0"}}'); + const result = detectProjectType(dir); + assert.ok(result.frameworks.includes('react')); + assert.ok(result.frameworks.includes('express')); + assert.strictEqual(result.primary, 'fullstack'); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Dependency reader tests + console.log('\nDependency Readers:'); + + if (test('getPackageJsonDeps reads deps and devDeps', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'package.json', '{"dependencies":{"react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}'); + const deps = getPackageJsonDeps(dir); + assert.ok(deps.includes('react')); + assert.ok(deps.includes('typescript')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('getPythonDeps reads requirements.txt', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'requirements.txt', 'flask>=3.0\n# comment\nrequests==2.31\n-r other.txt'); + const deps = getPythonDeps(dir); + assert.ok(deps.includes('flask')); + assert.ok(deps.includes('requests')); + assert.ok(!deps.includes('-r')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('getGoDeps reads go.mod require block', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'go.mod', 'module test\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n\tgithub.com/lib/pq v1.10.9\n)'); + const deps = getGoDeps(dir); + assert.ok(deps.some(d => d.includes('gin-gonic/gin'))); + assert.ok(deps.some(d => d.includes('lib/pq'))); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('getRustDeps reads Cargo.toml', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'Cargo.toml', '[package]\nname = "test"\n\n[dependencies]\nserde = "1.0"\ntokio = { version = "1.0", features = ["full"] }'); + const deps = getRustDeps(dir); + assert.ok(deps.includes('serde')); + assert.ok(deps.includes('tokio')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('returns empty arrays for missing files', () => { + const dir = createTempDir(); + try { + assert.deepStrictEqual(getPackageJsonDeps(dir), []); + assert.deepStrictEqual(getPythonDeps(dir), []); + assert.deepStrictEqual(getGoDeps(dir), []); + assert.deepStrictEqual(getRustDeps(dir), []); + assert.deepStrictEqual(getComposerDeps(dir), []); + assert.deepStrictEqual(getElixirDeps(dir), []); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Elixir detection + console.log('\nElixir Detection:'); + + if (test('detects elixir from mix.exs', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'mix.exs', 'defmodule Test.MixProject do\n defp deps do\n [{:phoenix, "~> 1.7"},\n {:ecto, "~> 3.0"}]\n end\nend'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('elixir')); + assert.ok(result.frameworks.includes('phoenix')); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Edge cases + console.log('\nEdge Cases:'); + + if (test('handles non-existent directory gracefully', () => { + const result = detectProjectType('/tmp/nonexistent-dir-' + Date.now()); + assert.strictEqual(result.primary, 'unknown'); + assert.deepStrictEqual(result.languages, []); + })) passed++; else failed++; + + if (test('handles malformed package.json', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'package.json', 'not valid json{{{'); + const deps = getPackageJsonDeps(dir); + assert.deepStrictEqual(deps, []); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + if (test('handles malformed composer.json', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'composer.json', '{invalid'); + const deps = getComposerDeps(dir); + assert.deepStrictEqual(deps, []); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + + // Summary + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/run-all.js b/tests/run-all.js index e1d08144..54d8c212 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -15,6 +15,7 @@ const testFiles = [ 'lib/package-manager.test.js', 'lib/session-manager.test.js', 'lib/session-aliases.test.js', + 'lib/project-detect.test.js', 'hooks/hooks.test.js', 'hooks/evaluate-session.test.js', 'hooks/suggest-compact.test.js',