diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abb7a2..ecc2cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ ### Documentation -- simplify the setup story around three cases: default rootless setup, single-project fallback, and explicit `project` retries -- clarify that issue #63 fixed the architecture and workspace-aware workflow, but issue #2 is not fully solved when the client does not provide enough project context +- simplify the setup story around a roots-first contract: roots-capable multi-project sessions, single-project fallback, and explicit `project` retries +- clarify that issue #63 fixed the architecture and workspace-aware workflow, but issue #2 is still only partially solved when the client does not provide roots or active-project context - remove the repo-local `init` / marker-file story from the public setup guidance ## [1.9.0](https://github.com/PatrickSys/codebase-context/compare/v1.8.2...v1.9.0) (2026-03-19) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1453bf7..b7a74a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ See [README.md](./README.md) for configuration with Claude Desktop, VS Code, Cur src/ analyzers/ angular/ # Angular-specific analysis + nextjs/ # Next.js routes, metadata, and client/server detection + react/ # React components, hooks, and context patterns generic/ # Fallback for non-Angular files core/ indexer.ts # Scans files, creates chunks @@ -34,8 +36,6 @@ src/ ## What Would Help -**React analyzer** - Biggest gap right now. Look at `src/analyzers/angular/index.ts` for the pattern. Needs to detect components, hooks, context usage, etc. - **Vue analyzer** - Same deal. Detect components, composables, Pinia stores. **Better search ranking** - The hybrid search in `src/core/search.ts` could use tuning. Currently uses RRF to combine semantic and keyword scores. @@ -44,9 +44,9 @@ src/ ## Adding a Framework Analyzer -1. Create `src/analyzers/react/index.ts` +1. Create `src/analyzers//index.ts` 2. Implement `FrameworkAnalyzer` interface -3. Register in `src/index.ts` +3. Register in `src/index.ts`, `src/cli.ts`, and `src/lib.ts` The interface is straightforward: diff --git a/README.md b/README.md index 76b5956..4655cb1 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ If you get `selection_required`, retry with one of the paths from `availableProj ## Language Support -10 languages with full symbol extraction via Tree-sitter: TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust. 30+ languages with indexing and retrieval coverage, including PHP, Ruby, Swift, Scala, Shell, and config formats. Angular has a dedicated analyzer; everything else uses the Generic analyzer with AST-aligned chunking when a grammar is available. +10 languages with full symbol extraction via Tree-sitter: TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust. 30+ languages with indexing and retrieval coverage, including PHP, Ruby, Swift, Scala, Shell, and config formats. Angular, React, and Next.js have dedicated analyzers; everything else uses the Generic analyzer with AST-aligned chunking when a grammar is available. ## Configuration @@ -156,6 +156,9 @@ If you get `selection_required`, retry with one of the paths from `availableProj | `CODEBASE_ROOT` | — | Bootstrap root for CLI and single-project MCP clients | | `CODEBASE_CONTEXT_DEBUG` | — | Set to `1` for verbose logging | | `EMBEDDING_MODEL` | `Xenova/bge-small-en-v1.5` | Local embedding model override | +| `CODEBASE_CONTEXT_HTTP` | — | Set to `1` to start in HTTP mode (same as `--http` flag) | +| `CODEBASE_CONTEXT_PORT` | `3100` | HTTP server port override (same as `--port`; ignored in stdio mode) | +| `CODEBASE_CONTEXT_CONFIG_PATH` | `~/.codebase-context/config.json` | Override the server config file path | ## Performance diff --git a/docs/capabilities.md b/docs/capabilities.md index 60796fb..6e0eca5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -21,6 +21,7 @@ Per-project config overrides supported today: - `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid - `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects + Copy-pasteable client config templates are shipped in the package: - `templates/mcp/stdio/.mcp.json` — stdio setup for `.mcp.json`-style clients @@ -104,6 +105,7 @@ Behavior matrix: Rules: - If the client provides workspace context, that becomes the trusted workspace boundary for the session. In practice this usually comes from MCP roots. +- Treat seamless multi-project routing as evidence-backed only for roots-capable hosts. Without roots, explicit fallback is still required. - If the server still cannot tell which project to use, a bootstrap path or explicit absolute `project` path remains the fallback. - `project` is the canonical explicit selector when routing is ambiguous. - `project` may point at a project path, file path, `file://` URI, or relative subproject path. @@ -267,6 +269,8 @@ Impact is 2-hop transitive: direct importers (hop 1) and their importers (hop 2) ## Analyzers - **Angular**: signals, standalone components, control flow syntax, lifecycle hooks, DI patterns, component metadata +- **React**: function/class components, custom hooks, context usage, memoization, Suspense, ecosystem signal extraction +- **Next.js**: App Router and Pages Router detection, route/API classification, route paths, `"use client"`, metadata exports - **Generic**: 30+ have indexing/retrieval coverage including PHP, Ruby, Swift, Scala, Shell, config/markup., 10 languages have full symbol extraction (Tree-sitter: TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust). Notes: @@ -289,6 +293,6 @@ Reproducible evaluation is shipped as a CLI entrypoint backed by shared scoring/ - **Symbol refs are not a call-graph.** `get_symbol_references` counts identifier-node occurrences in the AST (comments/strings excluded via Tree-sitter). It does not distinguish call sites from type annotations, variable assignments, or imports. Full call-site-specific analysis (`call_expression` nodes only) is a roadmap item. - **Impact is 2-hop max.** `computeImpactCandidates` walks direct importers then their importers. Full BFS reachability is on the roadmap. -- **Angular is the only framework with a rich dedicated analyzer.** All other languages go through the Generic analyzer (30+ languages, chunking + import graph, no framework-specific signal extraction). +- **Angular, React, and Next.js have dedicated analyzers.** All other languages go through the Generic analyzer (30+ languages, chunking + import graph, no framework-specific signal extraction). - **Default embedding model is `bge-small-en-v1.5` (512-token context).** Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` — sends code externally. - **Patterns are file-level frequency counts.** Not semantic clustering. Rising/Declining trend is derived from git commit recency for files using each pattern, not from usage semantics. diff --git a/docs/client-setup.md b/docs/client-setup.md index 4acdb02..23304e6 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -18,6 +18,18 @@ npx -y codebase-context --http --port 4000 Copy-pasteable templates: [`templates/mcp/stdio/.mcp.json`](../templates/mcp/stdio/.mcp.json) and [`templates/mcp/http/.mcp.json`](../templates/mcp/http/.mcp.json). +## Project routing contract + +Automatic multi-project routing is evidence-backed only when the MCP host announces workspace roots. Treat that as the primary path. + +If the host does not send roots, or still cannot tell which project is active, use one of the explicit fallbacks instead: + +- start the server with a single bootstrap path +- set `CODEBASE_ROOT` +- retry tool calls with `project` + +If multiple projects are available and no active project can be inferred safely, the server returns `selection_required` instead of guessing. + ## Claude Code ```bash @@ -197,9 +209,9 @@ Check these three flows: 1. **Single project** — call `search_codebase` or `metadata`. Routing is automatic. -2. **Multiple projects, one server entry** — open two repos or a monorepo. Call `codebase://context`. Expected: workspace overview, then automatic routing once a project is active. +2. **Multiple projects on a roots-capable host** — open two repos or a monorepo. Call `codebase://context`. Expected: workspace overview, then automatic routing once a project is active. -3. **Ambiguous selection** — start without a bootstrap path, call `search_codebase`. Expected: `selection_required`. Retry with `project` set to `apps/dashboard` or `/repos/customer-portal`. +3. **Ambiguous or no-roots selection** — start without a bootstrap path, call `search_codebase`. Expected: `selection_required`. Retry with `project` set to `apps/dashboard` or `/repos/customer-portal`. For monorepos, test all three selector forms: diff --git a/package.json b/package.json index 14881a5..f679e52 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,14 @@ "import": "./dist/analyzers/angular/index.js", "types": "./dist/analyzers/angular/index.d.ts" }, + "./analyzers/react": { + "import": "./dist/analyzers/react/index.js", + "types": "./dist/analyzers/react/index.d.ts" + }, + "./analyzers/nextjs": { + "import": "./dist/analyzers/nextjs/index.js", + "types": "./dist/analyzers/nextjs/index.d.ts" + }, "./analyzers/generic": { "import": "./dist/analyzers/generic/index.js", "types": "./dist/analyzers/generic/index.d.ts" diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 32e164e..c529a5c 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -1,3 +1,5 @@ +// Spawns vitest via process.execPath to avoid bin-resolution failures when +// Node is invoked directly (e.g. `node scripts/run-vitest.mjs`) without pnpm. import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; diff --git a/src/analyzers/angular/index.ts b/src/analyzers/angular/index.ts index 6208b88..dcede76 100644 --- a/src/analyzers/angular/index.ts +++ b/src/analyzers/angular/index.ts @@ -25,6 +25,7 @@ import { KEYWORD_INDEX_FILENAME } from '../../constants/codebase-context.js'; import { registerComplementaryPatterns } from '../../patterns/semantics.js'; +import { isFileNotFoundError } from '../shared/metadata.js'; interface AngularInput { name: string; @@ -911,7 +912,7 @@ export class AngularAnalyzer implements FrameworkAnalyzer { ...packageJson.devDependencies }; - const angularVersion = allDeps['@angular/core']?.replace(/[\^~]/, '') || 'unknown'; + const angularCoreVersion = allDeps['@angular/core']; // Detect state management const stateManagement: string[] = []; @@ -931,15 +932,17 @@ export class AngularAnalyzer implements FrameworkAnalyzer { if (allDeps['karma']) testingFrameworks.push('Karma'); if (allDeps['jest']) testingFrameworks.push('Jest'); - metadata.framework = { - name: 'Angular', - version: angularVersion, - type: 'angular', - variant: 'unknown', // Will be determined during analysis - stateManagement, - uiLibraries, - testingFrameworks - }; + if (angularCoreVersion) { + metadata.framework = { + name: 'Angular', + version: angularCoreVersion.replace(/[\^~]/, ''), + type: 'angular', + variant: 'unknown', // Will be determined during analysis + stateManagement, + uiLibraries, + testingFrameworks + }; + } // Convert dependencies metadata.dependencies = Object.entries(allDeps).map(([name, version]) => ({ @@ -948,7 +951,9 @@ export class AngularAnalyzer implements FrameworkAnalyzer { category: this.categorizeDependency(name) })); } catch (error) { - console.warn('Failed to read Angular project metadata:', error); + if (!isFileNotFoundError(error)) { + console.warn('Failed to read Angular project metadata:', error); + } } // Calculate statistics from existing index if available @@ -966,8 +971,6 @@ export class AngularAnalyzer implements FrameworkAnalyzer { const chunks = parsedObj && Array.isArray(parsedObj.chunks) ? (parsedObj.chunks as IndexChunk[]) : null; if (Array.isArray(chunks) && chunks.length > 0) { - console.error(`Loading statistics from ${indexPath}: ${chunks.length} chunks`); - metadata.statistics.totalFiles = new Set(chunks.map((c) => c.filePath)).size; metadata.statistics.totalLines = chunks.reduce( (sum, c) => sum + ((c.endLine ?? 0) - (c.startLine ?? 0) + 1), @@ -1005,8 +1008,9 @@ export class AngularAnalyzer implements FrameworkAnalyzer { metadata.architecture.layers = layerCounts; } } catch (error) { - // Index doesn't exist yet, keep statistics at 0 - console.warn('Failed to calculate statistics from index:', error); + if (!isFileNotFoundError(error)) { + console.warn('Failed to calculate statistics from index:', error); + } } return metadata; diff --git a/src/analyzers/nextjs/index.ts b/src/analyzers/nextjs/index.ts new file mode 100644 index 0000000..ca68383 --- /dev/null +++ b/src/analyzers/nextjs/index.ts @@ -0,0 +1,442 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse, type TSESTree } from '@typescript-eslint/typescript-estree'; +import type { + AnalysisResult, + CodeChunk, + CodeComponent, + CodebaseMetadata, + ExportStatement, + FrameworkAnalyzer, + ImportStatement +} from '../../types/index.js'; +import { createChunksFromCode } from '../../utils/chunking.js'; +import { categorizeDependency } from '../../utils/dependency-detection.js'; +import { + createEmptyStatistics, + isFileNotFoundError, + loadAnalyzerIndexStatistics, + normalizeAnalyzerVersion, + readAnalyzerPackageInfo +} from '../shared/metadata.js'; + +type DetectedPattern = { category: string; name: string }; +type NextRouter = 'app' | 'pages' | 'unknown'; +type NextRouteKind = 'page' | 'layout' | 'route' | 'api' | 'unknown'; + +interface NextRoutingInfo { + router: NextRouter; + kind: NextRouteKind; + routePath: string | null; + isClientComponent: boolean; + hasMetadata: boolean; +} + +export class NextJsAnalyzer implements FrameworkAnalyzer { + readonly name = 'nextjs'; + readonly version = '1.0.0'; + readonly supportedExtensions = ['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + readonly priority = 90; + + canAnalyze(filePath: string, content?: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + if (!this.supportedExtensions.includes(extension)) { + return false; + } + + if (isInAppRouter(filePath) || isInPagesRouter(filePath)) { + return true; + } + + return Boolean( + content && (/\bfrom\s+['"]next\//.test(content) || /\bfrom\s+['"]next['"]/.test(content)) + ); + } + + async analyze(filePath: string, content: string): Promise { + const extension = path.extname(filePath).toLowerCase(); + const language = + extension === '.ts' || extension === '.tsx' || extension === '.mts' || extension === '.cts' + ? 'typescript' + : 'javascript'; + const relativePath = path.relative(process.cwd(), filePath); + const routing = analyzeRouting(filePath, content); + + const imports: ImportStatement[] = []; + const exports: ExportStatement[] = []; + const dependencyNames = new Set(); + const components: CodeComponent[] = []; + const detectedPatterns: DetectedPattern[] = []; + + if (routing.router === 'app') { + detectedPatterns.push({ category: 'routing', name: 'Next.js App Router' }); + } + if (routing.router === 'pages') { + detectedPatterns.push({ category: 'routing', name: 'Next.js Pages Router' }); + } + if (routing.isClientComponent) { + detectedPatterns.push({ category: 'componentStyle', name: '"use client"' }); + } + if (routing.kind === 'route') { + detectedPatterns.push({ category: 'routing', name: 'Route Handler' }); + } + if (routing.kind === 'api') { + detectedPatterns.push({ category: 'routing', name: 'API Route' }); + } + if (routing.hasMetadata) { + detectedPatterns.push({ category: 'metadata', name: 'Next.js metadata' }); + } + + try { + const program = parse(content, { + loc: true, + range: true, + comment: true, + jsx: extension.includes('x'), + sourceType: 'module' + }); + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration' && typeof statement.source.value === 'string') { + const source = statement.source.value; + imports.push({ + source, + imports: statement.specifiers.map(getImportSpecifierName), + isDefault: statement.specifiers.some( + (specifier) => specifier.type === 'ImportDefaultSpecifier' + ), + isDynamic: false, + line: statement.loc?.start.line + }); + if (!source.startsWith('.') && !source.startsWith('/')) { + dependencyNames.add(getPackageName(source)); + } + } + + appendExports(exports, statement); + } + + if (routing.kind !== 'unknown') { + components.push({ + name: routing.routePath || path.basename(filePath), + type: 'module', + componentType: routing.kind, + startLine: 1, + endLine: content.split('\n').length, + metadata: { + nextjs: routing + } + }); + } + } catch (error) { + console.warn(`Failed to parse Next.js file ${filePath}:`, error); + } + + const chunks = await createChunksFromCode( + content, + filePath, + relativePath, + language, + components, + { + framework: 'nextjs', + detectedPatterns, + nextjs: routing + } + ); + + return { + filePath, + language, + framework: 'nextjs', + components, + imports, + exports, + dependencies: Array.from(dependencyNames) + .sort() + .map((name) => ({ + name, + category: categorizeDependency(name) + })), + metadata: { + analyzer: this.name, + nextjs: routing, + detectedPatterns + }, + chunks + }; + } + + async detectCodebaseMetadata(rootPath: string): Promise { + const metadata: CodebaseMetadata = { + name: path.basename(rootPath), + rootPath, + languages: [], + dependencies: [], + architecture: { + type: 'feature-based', + layers: createEmptyStatistics().componentsByLayer, + patterns: [] + }, + styleGuides: [], + documentation: [], + projectStructure: { + type: 'single-app' + }, + statistics: createEmptyStatistics(), + customMetadata: {} + }; + + try { + const packageInfo = await readAnalyzerPackageInfo(rootPath); + const routerPresence = await detectRouterPresence(rootPath); + metadata.name = packageInfo.projectName; + metadata.dependencies = Object.entries(packageInfo.allDependencies).map( + ([name, version]) => ({ + name, + version, + category: categorizeDependency(name) + }) + ); + metadata.framework = { + name: 'Next.js', + version: normalizeAnalyzerVersion(packageInfo.allDependencies.next), + type: 'nextjs', + variant: getFrameworkVariant(routerPresence), + stateManagement: detectDependencyList(packageInfo.allDependencies, [ + ['@reduxjs/toolkit', 'redux'], + ['redux', 'redux'], + ['zustand', 'zustand'], + ['jotai', 'jotai'], + ['recoil', 'recoil'], + ['mobx', 'mobx'] + ]), + uiLibraries: detectDependencyList(packageInfo.allDependencies, [ + ['tailwindcss', 'Tailwind'], + ['@mui/material', 'MUI'], + ['styled-components', 'styled-components'], + ['@radix-ui/react-slot', 'Radix UI'] + ]), + testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ + ['vitest', 'Vitest'], + ['jest', 'Jest'], + ['@testing-library/react', 'Testing Library'], + ['playwright', 'Playwright'], + ['cypress', 'Cypress'] + ]) + }; + metadata.customMetadata = { + nextjs: routerPresence + }; + } catch (error) { + if (!isFileNotFoundError(error)) { + console.warn('Failed to read Next.js project metadata:', error); + } + } + + metadata.statistics = await loadAnalyzerIndexStatistics(rootPath); + return metadata; + } + + summarize(chunk: CodeChunk): string { + const nextMetadata = + typeof chunk.metadata.nextjs === 'object' && chunk.metadata.nextjs + ? (chunk.metadata.nextjs as Partial) + : undefined; + + if (nextMetadata?.kind && nextMetadata.routePath) { + const componentMode = nextMetadata.isClientComponent ? 'client' : 'server'; + return `Next.js ${nextMetadata.kind} for "${nextMetadata.routePath}" (${nextMetadata.router || 'unknown'}, ${componentMode}).`; + } + + return `Next.js code in ${path.basename(chunk.filePath)}: lines ${chunk.startLine}-${chunk.endLine}.`; + } +} + +function analyzeRouting(filePath: string, content: string): NextRoutingInfo { + const normalizedPath = filePath.replace(/\\/g, '/'); + const baseName = path.basename(normalizedPath, path.extname(normalizedPath)); + const router: NextRouter = isInAppRouter(filePath) + ? 'app' + : isInPagesRouter(filePath) + ? 'pages' + : 'unknown'; + + let kind: NextRouteKind = 'unknown'; + if (router === 'app') { + const fileName = path.basename(normalizedPath); + if (fileName.startsWith('page.')) { + kind = 'page'; + } else if (fileName.startsWith('layout.')) { + kind = 'layout'; + } else if (fileName.startsWith('route.')) { + kind = 'route'; + } + } else if (router === 'pages') { + if (normalizedPath.includes('/pages/api/') || normalizedPath.includes('/src/pages/api/')) { + kind = 'api'; + } else if (!isPagesSystemFile(baseName)) { + kind = 'page'; + } + } + + return { + router, + kind, + routePath: router === 'unknown' ? null : computeRoutePath(router, normalizedPath), + isClientComponent: hasUseClientDirective(content), + hasMetadata: /\bexport\s+(?:const|function)\s+(metadata|generateMetadata)\b/.test(content) + }; +} + +function isInAppRouter(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + return normalizedPath.includes('/app/') || normalizedPath.includes('/src/app/'); +} + +function isInPagesRouter(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + return normalizedPath.includes('/pages/') || normalizedPath.includes('/src/pages/'); +} + +function computeRoutePath( + router: Exclude, + normalizedFilePath: string +): string | null { + const pathSegments = normalizedFilePath.split('/').filter(Boolean); + const routerDirectory = router === 'app' ? 'app' : 'pages'; + const routerIndex = pathSegments.lastIndexOf(routerDirectory); + if (routerIndex < 0) { + return null; + } + + const routeSegments = pathSegments.slice(routerIndex + 1); + const fileName = routeSegments.pop(); + if (!fileName) { + return '/'; + } + + const baseName = path.basename(fileName, path.extname(fileName)); + if (router === 'pages' && isPagesSystemFile(baseName)) { + return null; + } + if (router === 'pages' && baseName !== 'index') { + routeSegments.push(baseName); + } + + const cleanedSegments = routeSegments + .filter((segment) => !segment.startsWith('(') && !segment.startsWith('@')) + .map((segment) => (segment === 'index' ? '' : segment)) + .filter((segment) => segment.length > 0); + + const route = `/${cleanedSegments.join('/')}`; + return route === '/' ? '/' : route.replace(/\/+/g, '/'); +} + +function hasUseClientDirective(content: string): boolean { + return /^\s*(['"])use client\1\s*;?/.test(content); +} + +function isPagesSystemFile(baseName: string): boolean { + return ['_app', '_document', '_error', '_middleware'].includes(baseName); +} + +async function detectRouterPresence( + rootPath: string +): Promise<{ hasAppRouter: boolean; hasPagesRouter: boolean }> { + const appCandidates = [path.join(rootPath, 'app'), path.join(rootPath, 'src', 'app')]; + const pagesCandidates = [path.join(rootPath, 'pages'), path.join(rootPath, 'src', 'pages')]; + + return { + hasAppRouter: await anyExists(appCandidates), + hasPagesRouter: await anyExists(pagesCandidates) + }; +} + +async function anyExists(paths: string[]): Promise { + for (const candidatePath of paths) { + try { + await fs.stat(candidatePath); + return true; + } catch { + // Continue checking remaining candidates. + } + } + + return false; +} + +function getFrameworkVariant(routerPresence: { + hasAppRouter: boolean; + hasPagesRouter: boolean; +}): string { + if (routerPresence.hasAppRouter && routerPresence.hasPagesRouter) { + return 'hybrid'; + } + if (routerPresence.hasAppRouter) { + return 'app-router'; + } + if (routerPresence.hasPagesRouter) { + return 'pages-router'; + } + return 'unknown'; +} + +function detectDependencyList( + allDependencies: Record, + candidates: ReadonlyArray +): string[] { + return candidates + .filter(([dependencyName]) => Boolean(allDependencies[dependencyName])) + .map(([, label]) => label); +} + +function appendExports(exports: ExportStatement[], statement: TSESTree.Statement): void { + if (statement.type === 'ExportNamedDeclaration') { + const declaration = statement.declaration; + if (declaration?.type === 'FunctionDeclaration' && declaration.id) { + exports.push({ name: declaration.id.name, isDefault: false, type: 'function' }); + return; + } + if (declaration?.type === 'ClassDeclaration' && declaration.id) { + exports.push({ name: declaration.id.name, isDefault: false, type: 'class' }); + return; + } + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + exports.push({ name: declarator.id.name, isDefault: false, type: 'variable' }); + } + } + return; + } + for (const specifier of statement.specifiers) { + if (specifier.exported.type === 'Identifier') { + exports.push({ name: specifier.exported.name, isDefault: false, type: 're-export' }); + } + } + return; + } + + if (statement.type === 'ExportDefaultDeclaration') { + exports.push({ name: 'default', isDefault: true, type: 'default' }); + } +} + +function getImportSpecifierName(specifier: TSESTree.ImportClause): string { + if (specifier.type === 'ImportDefaultSpecifier') { + return 'default'; + } + if (specifier.type === 'ImportNamespaceSpecifier') { + return '*'; + } + return 'value' in specifier.imported ? String(specifier.imported.value) : specifier.imported.name; +} + +function getPackageName(importSource: string): string { + if (importSource.startsWith('@')) { + const [scope, name] = importSource.split('/'); + return name ? `${scope}/${name}` : importSource; + } + return importSource.split('/')[0] || importSource; +} diff --git a/src/analyzers/react/index.ts b/src/analyzers/react/index.ts new file mode 100644 index 0000000..8b63196 --- /dev/null +++ b/src/analyzers/react/index.ts @@ -0,0 +1,582 @@ +import path from 'path'; +import { parse, type TSESTree } from '@typescript-eslint/typescript-estree'; +import type { + AnalysisResult, + CodeChunk, + CodeComponent, + CodebaseMetadata, + ExportStatement, + FrameworkAnalyzer, + ImportStatement +} from '../../types/index.js'; +import { createChunksFromCode } from '../../utils/chunking.js'; +import { categorizeDependency } from '../../utils/dependency-detection.js'; +import { + createEmptyStatistics, + isFileNotFoundError, + loadAnalyzerIndexStatistics, + normalizeAnalyzerVersion, + readAnalyzerPackageInfo +} from '../shared/metadata.js'; + +type DetectedPattern = { category: string; name: string }; + +const BUILTIN_HOOKS = new Set([ + 'useState', + 'useEffect', + 'useMemo', + 'useCallback', + 'useReducer', + 'useRef', + 'useContext', + 'useLayoutEffect', + 'useImperativeHandle', + 'useDebugValue', + 'useDeferredValue', + 'useTransition', + 'useId', + 'useSyncExternalStore', + 'useInsertionEffect' +]); + +const REACT_LIBRARY_SIGNALS: ReadonlyArray<{ + source: string; + category: string; + name: string; +}> = [ + { source: 'react-hook-form', category: 'forms', name: 'react-hook-form' }, + { source: 'zod', category: 'validation', name: 'zod' }, + { source: '@tanstack/react-query', category: 'data', name: 'tanstack-query' }, + { source: '@reduxjs/toolkit', category: 'stateManagement', name: 'redux-toolkit' }, + { source: 'tailwindcss', category: 'styling', name: 'tailwind' } +]; + +interface ReactAstSummary { + components: CodeComponent[]; + builtinHooksUsed: string[]; + customHooks: string[]; + usesContext: boolean; + usesMemoization: boolean; + usesSuspense: boolean; +} + +export class ReactAnalyzer implements FrameworkAnalyzer { + readonly name = 'react'; + readonly version = '1.0.0'; + readonly supportedExtensions = ['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + readonly priority = 80; + + canAnalyze(filePath: string, content?: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + if (!this.supportedExtensions.includes(extension)) { + return false; + } + + if (extension === '.tsx' || extension === '.jsx') { + return true; + } + + if (!content) { + return false; + } + + return ( + /\bfrom\s+['"]react['"]/.test(content) || + /\brequire\(\s*['"]react['"]\s*\)/.test(content) || + /\bReact\.createElement\b/.test(content) || + /<[A-Za-z][^>]*>/.test(content) + ); + } + + async analyze(filePath: string, content: string): Promise { + const extension = path.extname(filePath).toLowerCase(); + const language = + extension === '.ts' || extension === '.tsx' || extension === '.mts' || extension === '.cts' + ? 'typescript' + : 'javascript'; + const relativePath = path.relative(process.cwd(), filePath); + + const imports: ImportStatement[] = []; + const exports: ExportStatement[] = []; + const dependencyNames = new Set(); + const importSources = new Set(); + const detectedPatterns: DetectedPattern[] = []; + let components: CodeComponent[] = []; + + try { + const program = parse(content, { + loc: true, + range: true, + comment: true, + jsx: extension.includes('x'), + sourceType: 'module' + }); + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration' && typeof statement.source.value === 'string') { + const source = statement.source.value; + importSources.add(getPackageName(source)); + imports.push({ + source, + imports: statement.specifiers.map(getImportSpecifierName), + isDefault: statement.specifiers.some( + (specifier) => specifier.type === 'ImportDefaultSpecifier' + ), + isDynamic: false, + line: statement.loc?.start.line + }); + + if (!source.startsWith('.') && !source.startsWith('/')) { + dependencyNames.add(getPackageName(source)); + } + } + + appendExports(exports, statement); + } + + const summary = summarizeReactProgram(program); + components = summary.components; + + if (summary.usesContext) { + detectedPatterns.push({ category: 'stateManagement', name: 'React Context' }); + } + if (summary.usesSuspense) { + detectedPatterns.push({ category: 'reactivity', name: 'Suspense' }); + } + if (summary.usesMemoization) { + detectedPatterns.push({ category: 'reactivity', name: 'Memoization' }); + } + if (summary.customHooks.length > 0) { + detectedPatterns.push({ category: 'reactHooks', name: 'Custom hooks' }); + } + if (summary.builtinHooksUsed.length > 0) { + detectedPatterns.push({ category: 'reactHooks', name: 'Built-in hooks' }); + } + + for (const signal of REACT_LIBRARY_SIGNALS) { + if (importSources.has(signal.source)) { + detectedPatterns.push({ category: signal.category, name: signal.name }); + } + } + } catch (error) { + console.warn(`Failed to parse React file ${filePath}:`, error); + } + + const chunks = await createChunksFromCode( + content, + filePath, + relativePath, + language, + components, + { + framework: 'react', + detectedPatterns + } + ); + + return { + filePath, + language, + framework: 'react', + components, + imports, + exports, + dependencies: Array.from(dependencyNames) + .sort() + .map((name) => ({ + name, + category: categorizeDependency(name) + })), + metadata: { + analyzer: this.name, + detectedPatterns + }, + chunks + }; + } + + async detectCodebaseMetadata(rootPath: string): Promise { + const metadata: CodebaseMetadata = { + name: path.basename(rootPath), + rootPath, + languages: [], + dependencies: [], + architecture: { + type: 'feature-based', + layers: createEmptyStatistics().componentsByLayer, + patterns: [] + }, + styleGuides: [], + documentation: [], + projectStructure: { + type: 'single-app' + }, + statistics: createEmptyStatistics(), + customMetadata: {} + }; + + try { + const packageInfo = await readAnalyzerPackageInfo(rootPath); + metadata.name = packageInfo.projectName; + metadata.dependencies = Object.entries(packageInfo.allDependencies).map( + ([name, version]) => ({ + name, + version, + category: categorizeDependency(name) + }) + ); + metadata.framework = { + name: 'React', + version: normalizeAnalyzerVersion(packageInfo.allDependencies.react), + type: 'react', + variant: 'unknown', + stateManagement: detectDependencyList(packageInfo.allDependencies, [ + ['@reduxjs/toolkit', 'redux'], + ['redux', 'redux'], + ['zustand', 'zustand'], + ['jotai', 'jotai'], + ['recoil', 'recoil'], + ['mobx', 'mobx'] + ]), + uiLibraries: detectDependencyList(packageInfo.allDependencies, [ + ['tailwindcss', 'Tailwind'], + ['@mui/material', 'MUI'], + ['styled-components', 'styled-components'], + ['@radix-ui/react-slot', 'Radix UI'] + ]), + testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ + ['vitest', 'Vitest'], + ['jest', 'Jest'], + ['@testing-library/react', 'Testing Library'], + ['playwright', 'Playwright'], + ['cypress', 'Cypress'] + ]) + }; + } catch (error) { + if (!isFileNotFoundError(error)) { + console.warn('Failed to read React project metadata:', error); + } + } + + metadata.statistics = await loadAnalyzerIndexStatistics(rootPath); + return metadata; + } + + summarize(chunk: CodeChunk): string { + const componentName = + typeof chunk.metadata.componentName === 'string' ? chunk.metadata.componentName : undefined; + if (componentName && chunk.componentType) { + return `${componentName} (${chunk.componentType}) in ${path.basename(chunk.filePath)}.`; + } + if (componentName) { + return `${componentName} in ${path.basename(chunk.filePath)}.`; + } + return `React code in ${path.basename(chunk.filePath)}: lines ${chunk.startLine}-${chunk.endLine}.`; + } +} + +function summarizeReactProgram(program: TSESTree.Program): ReactAstSummary { + const components: CodeComponent[] = []; + const builtinHooksUsed = new Set(); + const customHooks = new Set(); + let usesContext = false; + let usesMemoization = false; + let usesSuspense = false; + + walkAst(program, (node, parent) => { + if (node.type === 'CallExpression') { + const calleeName = getCalleeName(node.callee); + if (calleeName && BUILTIN_HOOKS.has(calleeName)) { + builtinHooksUsed.add(calleeName); + } + if (calleeName === 'createContext' || calleeName === 'useContext') { + usesContext = true; + } + if (calleeName === 'memo' || calleeName === 'useMemo' || calleeName === 'useCallback') { + usesMemoization = true; + } + if (calleeName === 'lazy') { + usesSuspense = true; + } + if ( + calleeName === 'createContext' && + parent?.type === 'VariableDeclarator' && + parent.id.type === 'Identifier' + ) { + usesContext = true; + } + } + + if (node.type === 'JSXElement') { + const tagName = getJsxTagName(node.openingElement.name); + if (tagName === 'Suspense' || tagName === 'React.Suspense') { + usesSuspense = true; + } + if (tagName?.endsWith('.Provider') || tagName?.endsWith('.Consumer')) { + usesContext = true; + } + } + + if (node.type === 'FunctionDeclaration' && node.id?.name) { + const name = node.id.name; + if (isCustomHookName(name)) { + customHooks.add(name); + components.push(toComponent(name, node, 'function', 'hook', { reactType: 'custom-hook' })); + } else if (isComponentName(name) && containsJsx(node.body)) { + components.push( + toComponent(name, node, 'function', 'component', { reactType: 'function-component' }) + ); + } + } + + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + const variableName = node.id.name; + if ( + node.init && + (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression') + ) { + if (isCustomHookName(variableName)) { + customHooks.add(variableName); + components.push( + toComponent(variableName, node, 'function', 'hook', { reactType: 'custom-hook' }) + ); + } else if (isComponentName(variableName) && containsJsx(node.init.body)) { + components.push( + toComponent(variableName, node, 'function', 'component', { + reactType: 'function-component' + }) + ); + } + } + } + + if (node.type === 'ClassDeclaration' && node.id?.name && isReactComponentSuperclass(node)) { + components.push( + toComponent(node.id.name, node, 'class', 'component', { reactType: 'class-component' }) + ); + } + }); + + return { + components: dedupeComponents(components), + builtinHooksUsed: Array.from(builtinHooksUsed).sort(), + customHooks: Array.from(customHooks).sort(), + usesContext, + usesMemoization, + usesSuspense + }; +} + +function appendExports(exports: ExportStatement[], statement: TSESTree.Statement): void { + if (statement.type === 'ExportNamedDeclaration') { + const declaration = statement.declaration; + if (declaration?.type === 'FunctionDeclaration' && declaration.id) { + exports.push({ name: declaration.id.name, isDefault: false, type: 'function' }); + return; + } + if (declaration?.type === 'ClassDeclaration' && declaration.id) { + exports.push({ name: declaration.id.name, isDefault: false, type: 'class' }); + return; + } + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + exports.push({ name: declarator.id.name, isDefault: false, type: 'variable' }); + } + } + return; + } + for (const specifier of statement.specifiers) { + if (specifier.exported.type === 'Identifier') { + exports.push({ name: specifier.exported.name, isDefault: false, type: 're-export' }); + } + } + return; + } + + if (statement.type === 'ExportDefaultDeclaration') { + exports.push({ name: 'default', isDefault: true, type: 'default' }); + } +} + +function getImportSpecifierName(specifier: TSESTree.ImportClause): string { + if (specifier.type === 'ImportDefaultSpecifier') { + return 'default'; + } + if (specifier.type === 'ImportNamespaceSpecifier') { + return '*'; + } + return getModuleExportedName(specifier.imported); +} + +function getPackageName(importSource: string): string { + if (importSource.startsWith('@')) { + const [scope, name] = importSource.split('/'); + return name ? `${scope}/${name}` : importSource; + } + return importSource.split('/')[0] || importSource; +} + +function detectDependencyList( + allDependencies: Record, + candidates: ReadonlyArray +): string[] { + return candidates + .filter(([dependencyName]) => Boolean(allDependencies[dependencyName])) + .map(([, label]) => label); +} + +function isComponentName(name: string): boolean { + return /^[A-Z]/.test(name); +} + +function isCustomHookName(name: string): boolean { + return /^use[A-Z0-9]/.test(name); +} + +function isReactComponentSuperclass(node: TSESTree.ClassDeclaration): boolean { + const superClass = node.superClass; + if (!superClass) { + return false; + } + if (superClass.type === 'Identifier') { + return superClass.name === 'Component' || superClass.name === 'PureComponent'; + } + if ( + superClass.type === 'MemberExpression' && + superClass.object.type === 'Identifier' && + superClass.property.type === 'Identifier' + ) { + return ( + superClass.object.name === 'React' && + (superClass.property.name === 'Component' || superClass.property.name === 'PureComponent') + ); + } + return false; +} + +function containsJsx(node: TSESTree.Node | TSESTree.Node[] | null): boolean { + if (!node) { + return false; + } + + let found = false; + walkAst(node, (candidate) => { + if (candidate.type === 'JSXElement' || candidate.type === 'JSXFragment') { + found = true; + } + }); + return found; +} + +function getCalleeName(node: TSESTree.Expression | TSESTree.Super): string | null { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Super') { + return null; + } + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.property.type === 'Identifier' + ) { + return node.property.name; + } + return null; +} + +function getJsxTagName( + node: TSESTree.JSXTagNameExpression | TSESTree.JSXIdentifier +): string | null { + if (node.type === 'JSXIdentifier') { + return node.name; + } + if (node.type === 'JSXMemberExpression') { + const objectName = getJsxTagName(node.object); + const propertyName = getJsxTagName(node.property); + return objectName && propertyName ? `${objectName}.${propertyName}` : null; + } + return null; +} + +function getModuleExportedName(node: TSESTree.Identifier | TSESTree.StringLiteral): string { + return node.type === 'Identifier' ? node.name : String(node.value); +} + +function toComponent( + name: string, + node: TSESTree.FunctionDeclaration | TSESTree.VariableDeclarator | TSESTree.ClassDeclaration, + type: string, + componentType: string, + metadata: Record +): CodeComponent { + return { + name, + type, + componentType, + startLine: node.loc?.start.line ?? 1, + endLine: node.loc?.end.line ?? node.loc?.start.line ?? 1, + metadata + }; +} + +function dedupeComponents(components: CodeComponent[]): CodeComponent[] { + const seen = new Set(); + return components.filter((component) => { + const key = `${component.name}:${component.startLine}:${component.endLine}:${component.componentType}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function walkAst( + root: TSESTree.Node | TSESTree.Node[], + visit: (node: TSESTree.Node, parent: TSESTree.Node | null) => void +): void { + const pending: Array<{ node: TSESTree.Node; parent: TSESTree.Node | null }> = []; + if (Array.isArray(root)) { + for (const node of root) { + pending.push({ node, parent: null }); + } + } else { + pending.push({ node: root, parent: null }); + } + + const visited = new Set(); + while (pending.length > 0) { + const next = pending.pop(); + if (!next || visited.has(next.node)) { + continue; + } + visited.add(next.node); + visit(next.node, next.parent); + + for (const child of getChildNodes(next.node)) { + pending.push({ node: child, parent: next.node }); + } + } +} + +function getChildNodes(node: TSESTree.Node): TSESTree.Node[] { + const children: TSESTree.Node[] = []; + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isNode(item)) { + children.push(item); + } + } + } else if (isNode(value)) { + children.push(value); + } + } + return children; +} + +function isNode(value: unknown): value is TSESTree.Node { + return value !== null && typeof value === 'object' && 'type' in value; +} diff --git a/src/analyzers/shared/metadata.ts b/src/analyzers/shared/metadata.ts new file mode 100644 index 0000000..233274b --- /dev/null +++ b/src/analyzers/shared/metadata.ts @@ -0,0 +1,172 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { CodebaseMetadata } from '../../types/index.js'; +import { + CODEBASE_CONTEXT_DIRNAME, + KEYWORD_INDEX_FILENAME +} from '../../constants/codebase-context.js'; +import { normalizePackageVersion } from '../../utils/workspace-detection.js'; + +interface PackageJsonShape { + name?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +interface IndexChunkLike { + filePath: string; + startLine: number; + endLine: number; + componentType?: string; + layer?: string; +} + +interface KeywordIndexFile { + chunks?: unknown; +} + +const DEFAULT_INDEX_STATS_MAX_MB = 20; + +export interface AnalyzerPackageInfo { + projectName: string; + allDependencies: Record; +} + +export async function readAnalyzerPackageInfo(rootPath: string): Promise { + const packageJsonPath = path.join(rootPath, 'package.json'); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as PackageJsonShape; + + return { + projectName: packageJson.name || path.basename(rootPath), + allDependencies: { + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}), + ...(packageJson.peerDependencies || {}) + } + }; +} + +export function normalizeAnalyzerVersion(version: string | undefined): string { + return normalizePackageVersion(version) || 'unknown'; +} + +export function createEmptyStatistics(): CodebaseMetadata['statistics'] { + return { + totalFiles: 0, + totalLines: 0, + totalComponents: 0, + componentsByType: {}, + componentsByLayer: { + presentation: 0, + business: 0, + data: 0, + state: 0, + core: 0, + shared: 0, + feature: 0, + infrastructure: 0, + unknown: 0 + } + }; +} + +export async function loadAnalyzerIndexStatistics( + rootPath: string +): Promise { + const statistics = createEmptyStatistics(); + const indexPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME); + + try { + const stat = await fs.stat(indexPath); + const maxBytes = getIndexStatsMaxBytes(); + if (maxBytes === 0 || stat.size > maxBytes) { + return statistics; + } + + const parsed = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as + | KeywordIndexFile + | unknown[]; + if (Array.isArray(parsed)) { + return statistics; + } + + const chunks = Array.isArray(parsed.chunks) ? parsed.chunks : null; + if (!chunks) { + return statistics; + } + + const typedChunks = chunks.filter(isIndexChunkLike); + if (typedChunks.length === 0) { + return statistics; + } + + statistics.totalFiles = new Set(typedChunks.map((chunk) => chunk.filePath)).size; + statistics.totalLines = typedChunks.reduce( + (sum, chunk) => sum + (chunk.endLine - chunk.startLine + 1), + 0 + ); + + for (const chunk of typedChunks) { + if (chunk.componentType) { + statistics.componentsByType[chunk.componentType] = + (statistics.componentsByType[chunk.componentType] || 0) + 1; + statistics.totalComponents++; + } + + if (isArchitecturalLayer(chunk.layer, statistics.componentsByLayer)) { + statistics.componentsByLayer[chunk.layer] = + (statistics.componentsByLayer[chunk.layer] || 0) + 1; + } + } + } catch { + return statistics; + } + + return statistics; +} + +export function isFileNotFoundError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} + +function getIndexStatsMaxBytes(): number { + const rawValue = process.env.CODEBASE_CONTEXT_INDEX_STATS_MAX_MB; + if (rawValue && rawValue.trim()) { + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsedValue) || Number.isNaN(parsedValue)) { + return DEFAULT_INDEX_STATS_MAX_MB * 1024 * 1024; + } + if (parsedValue <= 0) { + return 0; + } + return parsedValue * 1024 * 1024; + } + + return DEFAULT_INDEX_STATS_MAX_MB * 1024 * 1024; +} + +function isIndexChunkLike(value: unknown): value is IndexChunkLike { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.filePath === 'string' && + typeof candidate.startLine === 'number' && + typeof candidate.endLine === 'number' + ); +} + +function isArchitecturalLayer( + layer: unknown, + layers: CodebaseMetadata['statistics']['componentsByLayer'] +): layer is keyof typeof layers { + return typeof layer === 'string' && layer in layers; +} diff --git a/src/cli.ts b/src/cli.ts index 16e5834..8a0c45c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,12 +19,16 @@ import type { ToolContext } from './tools/index.js'; import type { IndexState } from './tools/types.js'; import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; +import { NextJsAnalyzer } from './analyzers/nextjs/index.js'; +import { ReactAnalyzer } from './analyzers/react/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { formatJson } from './cli-formatters.js'; import { handleMemoryCli } from './cli-memory.js'; export { handleMemoryCli } from './cli-memory.js'; analyzerRegistry.register(new AngularAnalyzer()); +analyzerRegistry.register(new NextJsAnalyzer()); +analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); const _CLI_COMMANDS = [ diff --git a/src/core/indexer.ts b/src/core/indexer.ts index ddb7140..0733ef8 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -273,6 +273,7 @@ export class CodebaseIndexer { const defaultConfig: CodebaseConfig = { analyzers: { angular: { enabled: true, priority: 100 }, + nextjs: { enabled: false, priority: 90 }, react: { enabled: false, priority: 90 }, vue: { enabled: false, priority: 90 }, generic: { enabled: true, priority: 10 } @@ -1238,7 +1239,7 @@ export class CodebaseIndexer { rootPath: incoming.rootPath || base.rootPath, languages: [...new Set([...base.languages, ...incoming.languages])], // Merge and deduplicate dependencies: this.mergeDependencies(base.dependencies, incoming.dependencies), - framework: incoming.framework || base.framework, // Framework from higher priority analyzer wins + framework: base.framework || incoming.framework, // Framework from higher priority analyzer wins architecture: { type: incoming.architecture?.type || base.architecture.type, layers: this.mergeLayers(base.architecture.layers, incoming.architecture?.layers), diff --git a/src/index.ts b/src/index.ts index a520ed2..e590f55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ import type { } from './types/index.js'; import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; +import { NextJsAnalyzer } from './analyzers/nextjs/index.js'; +import { ReactAnalyzer } from './analyzers/react/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { IndexCorruptedError } from './errors/index.js'; import { appendMemoryFile } from './memory/store.js'; @@ -70,6 +72,8 @@ import { } from './project-state.js'; analyzerRegistry.register(new AngularAnalyzer()); +analyzerRegistry.register(new NextJsAnalyzer()); +analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); // Flags that are NOT project paths — skip them when resolving the bootstrap root. @@ -1667,10 +1671,8 @@ async function applyServerConfig( configRoots.set(rootKey, { rootPath: proj.root }); registerKnownRoot(proj.root); const runtimeOverrides = buildProjectRuntimeOverrides(proj); - if (Object.keys(runtimeOverrides).length > 0) { - const project = getOrCreateProject(proj.root); - project.runtimeOverrides = runtimeOverrides; - } + const project = getOrCreateProject(proj.root); + project.runtimeOverrides = runtimeOverrides; } catch { console.error(`[config] Skipping inaccessible project root: ${proj.root}`); } diff --git a/src/lib.ts b/src/lib.ts index ab6194b..28f4d90 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -11,11 +11,15 @@ * VectorStorage, * analyzerRegistry, * AngularAnalyzer, + * NextJsAnalyzer, + * ReactAnalyzer, * GenericAnalyzer * } from 'codebase-context'; * * // Register analyzers * analyzerRegistry.register(new AngularAnalyzer()); + * analyzerRegistry.register(new NextJsAnalyzer()); + * analyzerRegistry.register(new ReactAnalyzer()); * analyzerRegistry.register(new GenericAnalyzer()); * * // Create and run indexer @@ -61,6 +65,10 @@ export { // Framework analyzers export { AngularAnalyzer } from './analyzers/angular/index.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; +export { NextJsAnalyzer } from './analyzers/nextjs/index.js'; +import { NextJsAnalyzer } from './analyzers/nextjs/index.js'; +export { ReactAnalyzer } from './analyzers/react/index.js'; +import { ReactAnalyzer } from './analyzers/react/index.js'; export { GenericAnalyzer } from './analyzers/generic/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; @@ -153,6 +161,12 @@ export function createIndexer( if (!analyzerRegistry.get('angular')) { analyzerRegistry.register(new AngularAnalyzer()); } + if (!analyzerRegistry.get('nextjs')) { + analyzerRegistry.register(new NextJsAnalyzer()); + } + if (!analyzerRegistry.get('react')) { + analyzerRegistry.register(new ReactAnalyzer()); + } if (!analyzerRegistry.get('generic')) { analyzerRegistry.register(new GenericAnalyzer()); } diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index 8fd3237..e388171 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -67,7 +67,7 @@ export const definition: Tool = { properties: { framework: { type: 'string', - description: 'Filter by framework (angular, react, vue)' + description: 'Filter by framework (angular, react, nextjs, vue)' }, language: { type: 'string', diff --git a/src/types/index.ts b/src/types/index.ts index ca39bea..484e21c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -206,7 +206,7 @@ export interface CodebaseMetadata { export interface FrameworkInfo { name: string; version: string; - type: 'angular' | 'react' | 'vue' | 'svelte' | 'solid' | 'other'; + type: 'angular' | 'react' | 'nextjs' | 'vue' | 'svelte' | 'solid' | 'other'; variant?: string; // 'standalone', 'module-based', 'class-components', etc. stateManagement?: string[]; // 'ngrx', 'redux', 'zustand', 'pinia', etc. uiLibraries?: string[]; @@ -456,6 +456,7 @@ export interface CodebaseConfig { analyzers: { angular?: AnalyzerConfig; react?: AnalyzerConfig; + nextjs?: AnalyzerConfig; vue?: AnalyzerConfig; generic?: AnalyzerConfig; [key: string]: AnalyzerConfig | undefined; diff --git a/src/utils/language-detection.ts b/src/utils/language-detection.ts index 7999d84..2cc8bac 100644 --- a/src/utils/language-detection.ts +++ b/src/utils/language-detection.ts @@ -176,6 +176,9 @@ function buildCodeExtensions(extraExtensions?: Iterable): Set { return merged; } +// Cached default set — built once at module load, reused by callers that pass no extra extensions. +const defaultCodeExtensions: ReadonlySet = buildCodeExtensions(); + /** * Detect language from file path */ @@ -193,7 +196,11 @@ export function isCodeFile( ): boolean { const ext = path.extname(filePath).toLowerCase(); const supportedExtensions = - extensions instanceof Set ? extensions : buildCodeExtensions(extensions); + extensions instanceof Set + ? extensions + : extensions + ? buildCodeExtensions(extensions) + : defaultCodeExtensions; return supportedExtensions.has(ext); } diff --git a/tests/analyzer-registry.test.ts b/tests/analyzer-registry.test.ts index dbefeca..73d0712 100644 --- a/tests/analyzer-registry.test.ts +++ b/tests/analyzer-registry.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi } from 'vitest'; import { analyzerRegistry, AnalyzerRegistry } from '../src/core/analyzer-registry'; import { AngularAnalyzer } from '../src/analyzers/angular/index'; +import { NextJsAnalyzer } from '../src/analyzers/nextjs/index'; +import { ReactAnalyzer } from '../src/analyzers/react/index'; import { GenericAnalyzer } from '../src/analyzers/generic/index'; // Register default analyzers analyzerRegistry.register(new AngularAnalyzer()); +analyzerRegistry.register(new NextJsAnalyzer()); +analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); describe('AnalyzerRegistry', () => { @@ -43,11 +47,13 @@ describe('AnalyzerRegistry', () => { } }); - it('should include default analyzers (Angular, Generic)', () => { + it('should include default analyzers (Angular, Next.js, React, Generic)', () => { const analyzers = analyzerRegistry.getAll(); const names = analyzers.map((a) => a.name); expect(names).toContain('angular'); + expect(names).toContain('nextjs'); + expect(names).toContain('react'); expect(names).toContain('generic'); }); }); @@ -59,6 +65,11 @@ describe('AnalyzerRegistry', () => { expect(angular?.name).toBe('angular'); }); + it('should return nextjs and react analyzers by name', () => { + expect(analyzerRegistry.get('nextjs')?.name).toBe('nextjs'); + expect(analyzerRegistry.get('react')?.name).toBe('react'); + }); + it('should return undefined for unknown analyzer', () => { const unknown = analyzerRegistry.get('unknown-analyzer'); expect(unknown).toBeUndefined(); @@ -68,11 +79,17 @@ describe('AnalyzerRegistry', () => { describe('priority ordering', () => { it('should have Angular higher priority than Generic', () => { const angular = analyzerRegistry.get('angular'); + const nextjs = analyzerRegistry.get('nextjs'); + const react = analyzerRegistry.get('react'); const generic = analyzerRegistry.get('generic'); expect(angular).toBeDefined(); + expect(nextjs).toBeDefined(); + expect(react).toBeDefined(); expect(generic).toBeDefined(); expect(angular!.priority).toBeGreaterThan(generic!.priority); + expect(nextjs!.priority).toBeGreaterThan(react!.priority); + expect(react!.priority).toBeGreaterThan(generic!.priority); }); }); }); diff --git a/tests/indexer-metadata.test.ts b/tests/indexer-metadata.test.ts index 2c4a08e..8b6e35c 100644 --- a/tests/indexer-metadata.test.ts +++ b/tests/indexer-metadata.test.ts @@ -3,6 +3,24 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CodebaseIndexer } from '../src/core/indexer'; +import { analyzerRegistry } from '../src/core/analyzer-registry'; +import { AngularAnalyzer } from '../src/analyzers/angular/index'; +import { NextJsAnalyzer } from '../src/analyzers/nextjs/index'; +import { ReactAnalyzer } from '../src/analyzers/react/index'; +import { GenericAnalyzer } from '../src/analyzers/generic/index'; + +if (!analyzerRegistry.get('angular')) { + analyzerRegistry.register(new AngularAnalyzer()); +} +if (!analyzerRegistry.get('nextjs')) { + analyzerRegistry.register(new NextJsAnalyzer()); +} +if (!analyzerRegistry.get('react')) { + analyzerRegistry.register(new ReactAnalyzer()); +} +if (!analyzerRegistry.get('generic')) { + analyzerRegistry.register(new GenericAnalyzer()); +} describe('CodebaseIndexer.detectMetadata', () => { let tempDir: string; @@ -26,7 +44,7 @@ describe('CodebaseIndexer.detectMetadata', () => { const metadata = await indexer.detectMetadata(); expect(metadata.rootPath).toBe(tempDir); - expect(metadata.name).toBe(path.basename(tempDir)); + expect(metadata.name).toBe('test-project'); }); it('should merge metadata from multiple analyzers', async () => { @@ -49,6 +67,28 @@ describe('CodebaseIndexer.detectMetadata', () => { expect(metadata.architecture.layers).toBeDefined(); }); + it('should prefer nextjs framework metadata over react when both apply', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'next-project', + dependencies: { + next: '^14.1.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + }, + }) + ); + + await fs.mkdir(path.join(tempDir, 'app'), { recursive: true }); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework?.type).toBe('nextjs'); + expect(metadata.framework?.name).toBe('Next.js'); + }); + it('should handle projects without package.json', async () => { const indexer = new CodebaseIndexer({ rootPath: tempDir }); const metadata = await indexer.detectMetadata(); diff --git a/tests/mcp-client-templates.test.ts b/tests/mcp-client-templates.test.ts index 1d40405..03cf820 100644 --- a/tests/mcp-client-templates.test.ts +++ b/tests/mcp-client-templates.test.ts @@ -133,4 +133,27 @@ describe('docs/capabilities.md transport documentation', () => { expect(caps).toContain('Codex'); expect(caps).toContain('Windsurf'); }); + + it('states the roots-first routing fallback explicitly', () => { + expect(caps).toContain('roots-capable hosts'); + expect(caps).toContain('explicit fallback is still required'); + }); +}); + +describe('docs/client-setup.md multi-project guidance', () => { + const clientSetup = readText('docs/client-setup.md'); + + it('documents the project routing contract', () => { + expect(clientSetup).toContain( + 'Automatic multi-project routing is evidence-backed only when the MCP host announces workspace roots.' + ); + expect(clientSetup).toContain( + 'the server returns `selection_required` instead of guessing' + ); + }); + + it('keeps the three verification flows aligned with the roots-first contract', () => { + expect(clientSetup).toContain('Multiple projects on a roots-capable host'); + expect(clientSetup).toContain('Ambiguous or no-roots selection'); + }); }); diff --git a/tests/multi-project-routing.test.ts b/tests/multi-project-routing.test.ts index e091be9..3ee1d68 100644 --- a/tests/multi-project-routing.test.ts +++ b/tests/multi-project-routing.test.ts @@ -362,6 +362,45 @@ describe('multi-project routing', () => { } }); + it('triggers a background rebuild for a corrupted explicit project without falling back to cwd', async () => { + delete process.env.CODEBASE_ROOT; + delete process.argv[2]; + + await fs.rm(path.join(secondaryRoot, CODEBASE_CONTEXT_DIRNAME, INDEX_META_FILENAME), { + force: true + }); + + const { server, refreshKnownRootsFromClient } = await import('../src/index.js'); + const typedServer = server as unknown as TestServer & { + listRoots: () => Promise<{ roots: Array<{ uri: string; name?: string }> }>; + }; + const originalListRoots = typedServer.listRoots.bind(typedServer); + const handler = typedServer._requestHandlers.get('tools/call'); + if (!handler) throw new Error('tools/call handler not registered'); + + typedServer.listRoots = vi.fn().mockRejectedValue(new Error('roots unsupported')); + + try { + await refreshKnownRootsFromClient(); + const response = await callTool(handler, 21, 'search_codebase', { + query: 'feature', + project: secondaryRoot + }); + const payload = parsePayload(response) as { + status: string; + message: string; + index?: { action?: string; reason?: string }; + }; + + expect(payload.status).toBe('indexing'); + expect(payload.message).toContain('retry shortly'); + expect(payload.index?.action).toBe('rebuild-started'); + expect(String(payload.index?.reason || '')).toContain('Index meta'); + } finally { + typedServer.listRoots = originalListRoots; + } + }); + it('returns selection_required instead of silently falling back to cwd when startup is rootless and unresolved', async () => { delete process.env.CODEBASE_ROOT; delete process.argv[2]; diff --git a/tests/nextjs-analyzer.test.ts b/tests/nextjs-analyzer.test.ts new file mode 100644 index 0000000..9bcdfc7 --- /dev/null +++ b/tests/nextjs-analyzer.test.ts @@ -0,0 +1,139 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { NextJsAnalyzer } from '../src/analyzers/nextjs/index'; + +describe('NextJsAnalyzer', () => { + it('canAnalyze detects Next imports even outside app/pages paths', () => { + const analyzer = new NextJsAnalyzer(); + + expect(analyzer.canAnalyze('/tmp/file.tsx', 'import Link from "next/link";')).toBe(true); + expect(analyzer.canAnalyze('/tmp/file.tsx', 'import { headers } from "next/headers";')).toBe( + true + ); + expect(analyzer.canAnalyze('/tmp/file.tsx', 'import React from "react";')).toBe(false); + }); + + it('detects app router pages, client components, and metadata exports', async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), 'app', 'settings', 'page.tsx'); + + const code = ` +"use client"; +export const metadata = { title: "Settings" }; +export default function Page() { return
; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.framework).toBe('nextjs'); + expect(result.metadata.nextjs).toMatchObject({ + router: 'app', + kind: 'page', + routePath: '/settings', + isClientComponent: true, + hasMetadata: true + }); + }); + + it('handles route groups, dynamic segments, and pages router API routes', async () => { + const analyzer = new NextJsAnalyzer(); + + const groupedPage = await analyzer.analyze( + path.join(process.cwd(), 'app', '(marketing)', '@modal', 'blog', '[id]', 'page.tsx'), + 'export default function Page() { return
; }' + ); + expect(groupedPage.metadata.nextjs).toMatchObject({ + router: 'app', + routePath: '/blog/[id]' + }); + + const apiRoute = await analyzer.analyze( + path.join(process.cwd(), 'pages', 'api', 'health.ts'), + 'export default function handler() { return null; }' + ); + expect(apiRoute.metadata.nextjs).toMatchObject({ + router: 'pages', + kind: 'api', + routePath: '/api/health' + }); + }); + + it('does not treat _app as a pages route and infers metadata variants from disk', async () => { + const analyzer = new NextJsAnalyzer(); + const appShell = await analyzer.analyze( + path.join(process.cwd(), 'pages', '_app.tsx'), + 'export default function App() { return null; }' + ); + expect(appShell.metadata.nextjs).toMatchObject({ + router: 'pages', + kind: 'unknown', + routePath: null + }); + + const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `nextjs-${randomUUID()}`); + await mkdir(path.join(tempRoot, 'app'), { recursive: true }); + await mkdir(path.join(tempRoot, 'pages'), { recursive: true }); + + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify( + { + name: 'tmp-next', + dependencies: { + next: '^14.1.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + tailwindcss: '^3.4.0', + zustand: '^4.5.0', + vitest: '^1.3.0' + } + }, + null, + 2 + ), + 'utf-8' + ); + await mkdir(path.join(tempRoot, '.codebase-context'), { recursive: true }); + await writeFile( + path.join(tempRoot, '.codebase-context', 'index.json'), + JSON.stringify( + { + header: { format: 'codebase-context-keyword-index', version: '1.0.0' }, + chunks: [ + { + filePath: 'app/page.tsx', + startLine: 1, + endLine: 10, + componentType: 'page', + layer: 'presentation' + }, + { + filePath: 'pages/api/health.ts', + startLine: 1, + endLine: 5, + componentType: 'api', + layer: 'infrastructure' + } + ] + }, + null, + 2 + ), + 'utf-8' + ); + + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework).toMatchObject({ + type: 'nextjs', + variant: 'hybrid', + version: '14.1.0' + }); + expect(metadata.statistics.totalFiles).toBe(2); + expect(metadata.statistics.totalComponents).toBe(2); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/react-analyzer.test.ts b/tests/react-analyzer.test.ts new file mode 100644 index 0000000..1d9e9a8 --- /dev/null +++ b/tests/react-analyzer.test.ts @@ -0,0 +1,80 @@ +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { ReactAnalyzer } from '../src/analyzers/react/index'; + +describe('ReactAnalyzer', () => { + it('canAnalyze uses heuristics for non-JSX extensions', () => { + const analyzer = new ReactAnalyzer(); + + expect(analyzer.canAnalyze('/tmp/file.ts')).toBe(false); + expect(analyzer.canAnalyze('/tmp/file.ts', 'import React from "react";')).toBe(true); + expect(analyzer.canAnalyze('/tmp/file.js', 'const React = require("react");')).toBe(true); + expect(analyzer.canAnalyze('/tmp/file.js', 'React.createElement("div");')).toBe(true); + }); + + it('detects components, hooks, context, and ecosystem signals', async () => { + const analyzer = new ReactAnalyzer(); + const filePath = path.join(process.cwd(), 'src', 'components', 'MyWidget.tsx'); + + const code = ` +import React, { Component, Suspense, createContext, useContext, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; +import { configureStore } from "@reduxjs/toolkit"; +import "tailwindcss"; + +export const ThemeContext = createContext("light"); + +export function useTheme() { + const [count] = useState(0); + return useContext(ThemeContext) + count; +} + +export const MyWidget = () => { + const theme = useTheme(); + const value = useMemo(() => theme, [theme]); + useForm(); + z.string(); + useQuery({ queryKey: ["k"], queryFn: async () => 1 }); + configureStore({ reducer: {} }); + return
{value}
; +}; + +export class LegacyWidget extends Component { + render() { + return
; + } +} +`; + + const result = await analyzer.analyze(filePath, code); + + expect(result.framework).toBe('react'); + expect( + result.components.some((component) => component.componentType === 'hook' && component.name === 'useTheme') + ).toBe(true); + expect( + result.components.some( + (component) => component.componentType === 'component' && component.name === 'MyWidget' + ) + ).toBe(true); + expect( + result.components.some( + (component) => component.componentType === 'component' && component.name === 'LegacyWidget' + ) + ).toBe(true); + + const patterns = (result.metadata.detectedPatterns || []) as Array<{ category: string; name: string }>; + expect(patterns).toContainEqual({ category: 'stateManagement', name: 'React Context' }); + expect(patterns).toContainEqual({ category: 'reactivity', name: 'Suspense' }); + expect(patterns).toContainEqual({ category: 'reactivity', name: 'Memoization' }); + expect(patterns).toContainEqual({ category: 'reactHooks', name: 'Custom hooks' }); + expect(patterns).toContainEqual({ category: 'reactHooks', name: 'Built-in hooks' }); + expect(patterns).toContainEqual({ category: 'forms', name: 'react-hook-form' }); + expect(patterns).toContainEqual({ category: 'validation', name: 'zod' }); + expect(patterns).toContainEqual({ category: 'data', name: 'tanstack-query' }); + expect(patterns).toContainEqual({ category: 'stateManagement', name: 'redux-toolkit' }); + expect(patterns).toContainEqual({ category: 'styling', name: 'tailwind' }); + }); +});