From 64979f3dbe00e9aa81c88559188b24c851bc7191 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 08:58:01 -0600 Subject: [PATCH 1/6] - Fixed issue with output when mcp server is started. This fixes the following output so that we can work with working new code: ``` Registered analyzer: angular (priority: 100) Registered analyzer: generic (priority: 10) ``` --- package-lock.json | 6 ++++-- src/core/analyzer-registry.ts | 7 ++++++- src/index.ts | 24 ++++++++++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index c4998a0..bc0f927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codebase-context", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codebase-context", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@lancedb/lancedb": "^0.4.0", @@ -1133,6 +1133,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4108,6 +4109,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/core/analyzer-registry.ts b/src/core/analyzer-registry.ts index d72e9f6..4527498 100644 --- a/src/core/analyzer-registry.ts +++ b/src/core/analyzer-registry.ts @@ -16,7 +16,12 @@ export class AnalyzerRegistry { this.sortedAnalyzers = Array.from(this.analyzers.values()) .sort((a, b) => b.priority - a.priority); - console.error(`Registered analyzer: ${analyzer.name} (priority: ${analyzer.priority})`); + // Avoid noisy logging on MCP startup unless explicitly requested. + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error( + `Registered analyzer: ${analyzer.name} (priority: ${analyzer.priority})` + ); + } } unregister(name: string): boolean { diff --git a/src/index.ts b/src/index.ts index b6ec41a..2ac8baf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { promises as fs } from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import { glob } from "glob"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -1044,13 +1045,24 @@ async function main() { // Export server components for programmatic use export { server, performIndexing, resolveRootPath, shouldReindex, TOOLS }; -// Only auto-start when run directly as CLI (not when imported as module) -// Check if this module is the entry point -const isDirectRun = - process.argv[1]?.replace(/\\/g, "/").endsWith("index.js") || - process.argv[1]?.replace(/\\/g, "/").endsWith("index.ts"); +// Auto-start behavior: +// - When executed directly (node dist/index.js ...) +// - When launched under stdio (common for MCP clients that use `node -e "import(...)"`) +// Can be disabled by setting CODEBASE_CONTEXT_DISABLE_AUTOSTART=1 +const disableAutostart = ["1", "true", "yes"].includes( + (process.env.CODEBASE_CONTEXT_DISABLE_AUTOSTART ?? "").toLowerCase() +); + +const entryArgv = process.argv[1]; +const isEntrypoint = + typeof entryArgv === "string" && + path.resolve(entryArgv) === path.resolve(fileURLToPath(import.meta.url)); + +// For stdio-based MCP, stdin is typically a pipe. +// In Node, `process.stdin.isTTY` is `true` for terminals, and otherwise commonly `undefined`. +const isStdio = process.stdin?.isTTY !== true; -if (isDirectRun) { +if (!disableAutostart && (isEntrypoint || isStdio)) { main().catch((error) => { console.error("Fatal:", error); process.exit(1); From 80a063680b9b29dd362680e70072085764b43e9c Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 13:22:54 -0600 Subject: [PATCH 2/6] - feat(analyzers): add React + Next.js analyzers and ecosystem metadata --- src/analyzers/nextjs/index.ts | 509 ++++++++++++++++ src/analyzers/orchestration/ecosystem.ts | 133 +++++ src/analyzers/orchestration/package-json.ts | 161 +++++ src/analyzers/react/index.ts | 625 ++++++++++++++++++++ src/core/indexer.ts | 31 + src/index.ts | 35 +- src/lib.ts | 3 + src/types/index.ts | 2 +- src/utils/async-json.ts | 49 ++ 9 files changed, 1535 insertions(+), 13 deletions(-) create mode 100644 src/analyzers/nextjs/index.ts create mode 100644 src/analyzers/orchestration/ecosystem.ts create mode 100644 src/analyzers/orchestration/package-json.ts create mode 100644 src/analyzers/react/index.ts create mode 100644 src/utils/async-json.ts diff --git a/src/analyzers/nextjs/index.ts b/src/analyzers/nextjs/index.ts new file mode 100644 index 0000000..c6e9a00 --- /dev/null +++ b/src/analyzers/nextjs/index.ts @@ -0,0 +1,509 @@ +/** + * Next.js Analyzer - Framework-aware analysis for Next.js projects. + * + * Detects: + * - Router style (App Router vs Pages Router) per file + * - Routes and route kinds (page/layout/route handler/API) + * - Server vs Client components ("use client") + * - Metadata exports (metadata / generateMetadata) + * + * No runtime execution: filesystem + AST only. + */ + +import { promises as fs } from "fs"; +import path from "path"; +import { parse } from "@typescript-eslint/typescript-estree"; +import { + AnalysisResult, + CodebaseMetadata, + CodeChunk, + CodeComponent, + Dependency, + ExportStatement, + FrameworkAnalyzer, + ImportStatement, +} from "../../types/index.js"; +import { createChunksFromCode } from "../../utils/chunking.js"; +import { parseJsonInWorker } from "../../utils/async-json.js"; +import { + getPackageName, + mergeDependencies, + normalizePackageVersion, + readRootPackageJson, +} from "../orchestration/package-json.js"; + +type DetectedPattern = { category: string; name: string }; + +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 ext = path.extname(filePath).toLowerCase(); + if (!this.supportedExtensions.includes(ext)) return false; + + // Strong signal from path structure + if (isInNextAppRouter(filePath) || isInNextPagesRouter(filePath)) return true; + + // Content-based signal + if (content) { + if (/\bfrom\s+["']next\//.test(content)) return true; + if (/\bfrom\s+["']next["']/.test(content)) return true; + } + + return false; + } + + async analyze(filePath: string, content: string): Promise { + const ext = path.extname(filePath).toLowerCase(); + const isJsx = ext.includes("x"); + const language = + ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts" + ? "typescript" + : "javascript"; + const relativePath = path.relative(process.cwd(), filePath); + + const components: CodeComponent[] = []; + const imports: ImportStatement[] = []; + const exports: ExportStatement[] = []; + const dependencies: string[] = []; + const detectedPatterns: DetectedPattern[] = []; + + const routing = analyzeNextFileRouting(filePath, content); + + 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 ast = parse(content, { + loc: true, + range: true, + comment: true, + jsx: isJsx, + sourceType: "module", + }); + + // Imports / dependencies + for (const node of ast.body) { + if (node.type === "ImportDeclaration" && node.source.value) { + const source = node.source.value as string; + imports.push({ + source, + imports: node.specifiers.map((s: any) => { + if (s.type === "ImportDefaultSpecifier") return "default"; + if (s.type === "ImportNamespaceSpecifier") return "*"; + return s.imported?.name || s.local?.name || "unknown"; + }), + isDefault: node.specifiers.some( + (s: any) => s.type === "ImportDefaultSpecifier" + ), + isDynamic: false, + line: node.loc?.start.line, + }); + + if (!source.startsWith(".") && !source.startsWith("/")) { + dependencies.push(getPackageName(source)); + } + } + + // Named exports + if (node.type === "ExportNamedDeclaration") { + if (node.declaration?.type === "FunctionDeclaration" && node.declaration.id) { + exports.push({ name: node.declaration.id.name, isDefault: false, type: "function" }); + } else if (node.declaration?.type === "ClassDeclaration" && node.declaration.id) { + exports.push({ name: node.declaration.id.name, isDefault: false, type: "class" }); + } else if (node.declaration?.type === "VariableDeclaration") { + for (const decl of node.declaration.declarations) { + if (decl.id.type === "Identifier") { + exports.push({ name: decl.id.name, isDefault: false, type: "variable" }); + } + } + } else if (node.specifiers && node.specifiers.length > 0) { + for (const s of node.specifiers as any[]) { + if (s.exported?.name) exports.push({ name: s.exported.name, isDefault: false, type: "re-export" }); + } + } + } + + // Default export + if (node.type === "ExportDefaultDeclaration") { + exports.push({ name: "default", isDefault: true, type: "default" }); + } + } + + // Route/page component chunk + if (routing.kind) { + const startLine = 1; + const endLine = content.split("\n").length; + components.push({ + name: routing.routePath || path.basename(filePath), + type: "module", + componentType: routing.kind, + startLine, + endLine, + metadata: { + nextjs: routing, + }, + }); + } + } catch (error) { + console.warn(`Failed to parse Next.js file ${filePath}:`, error); + } + + const uniqueDependencies = Array.from(new Set(dependencies)).sort(); + + const chunks = await createChunksFromCode( + content, + filePath, + relativePath, + language, + components, + { + framework: "nextjs", + detectedPatterns, + nextjs: routing, + } + ); + + return { + filePath, + language, + framework: "nextjs", + components, + imports, + exports, + dependencies: uniqueDependencies.map((name) => ({ + name, + category: this.categorizeDependency(name), + })), + metadata: { + analyzer: this.name, + nextjs: routing, + detectedPatterns, + }, + chunks, + }; + } + + async detectCodebaseMetadata(rootPath: string): Promise { + const rootPkg = await readRootPackageJson(rootPath); + const packageJson = rootPkg?.packageJson; + + const projectName = packageJson?.name || path.basename(rootPath); + const allDeps = packageJson ? mergeDependencies(packageJson) : {}; + + const nextVersion = normalizePackageVersion(allDeps["next"]) || "unknown"; + + const dependencies: Dependency[] = Object.entries(allDeps).map(([name, version]) => ({ + name, + version: version as string, + category: this.categorizeDependency(name), + })); + + const routerPresence = await detectNextRouterPresence(rootPath); + + // Basic statistics from existing index if available (same pattern as Angular) + const statistics = await tryLoadIndexStatistics(rootPath); + + return { + name: projectName, + rootPath, + framework: { + name: "Next.js", + version: nextVersion, + type: "nextjs", + variant: routerPresence.hasAppRouter && routerPresence.hasPagesRouter + ? "hybrid" + : routerPresence.hasAppRouter + ? "app-router" + : routerPresence.hasPagesRouter + ? "pages-router" + : "unknown", + stateManagement: detectStateLibraries(allDeps), + uiLibraries: detectUiLibraries(allDeps), + testingFrameworks: detectTestingLibraries(allDeps), + }, + languages: [], + dependencies, + architecture: { + type: "feature-based", + layers: { + presentation: 0, + business: 0, + data: 0, + state: 0, + core: 0, + shared: 0, + feature: 0, + infrastructure: 0, + unknown: 0, + }, + patterns: [], + }, + styleGuides: [], + documentation: [], + projectStructure: { + type: "single-app", + }, + statistics, + customMetadata: { + nextjs: routerPresence, + }, + }; + } + + summarize(chunk: CodeChunk): string { + const next = chunk.metadata?.nextjs; + if (next?.kind && next?.routePath) { + const kind = next.kind; + const router = next.router || "unknown"; + const client = next.isClientComponent ? "client" : "server"; + return `Next.js ${kind} for "${next.routePath}" (${router}, ${client}).`; + } + return `Next.js code in ${path.basename(chunk.filePath)}: lines ${chunk.startLine}-${chunk.endLine}.`; + } + + private categorizeDependency(name: string): Dependency["category"] { + if (name === "next") return "framework"; + if (name === "react" || name === "react-dom") return "framework"; + if (name.startsWith("@reduxjs/") || name === "redux" || name === "zustand") return "state"; + if (name === "@tanstack/react-query" || name === "swr" || name === "@apollo/client") return "http"; + if (name === "tailwindcss" || name === "@mui/material" || name === "styled-components") return "ui"; + if (name === "vitest" || name === "jest" || name === "@testing-library/react") return "testing"; + if (name === "typescript" || name === "eslint" || name === "vite") return "build"; + return "other"; + } +} + +function isInNextAppRouter(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, "/"); + return normalized.includes("/app/") || normalized.includes("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/src/app/"); +} + +function isInNextPagesRouter(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, "/"); + return normalized.includes("/pages/") || normalized.includes("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/src/pages/"); +} + +function analyzeNextFileRouting(filePath: string, content: string): { + router: "app" | "pages" | "unknown"; + kind: "page" | "layout" | "route" | "api" | "unknown"; + routePath: string | null; + isClientComponent: boolean; + hasMetadata: boolean; +} { + const normalized = filePath.replace(/\\/g, "/"); + const base = path.basename(normalized); + const baseName = path.basename(base, path.extname(base)); + + const router: "app" | "pages" | "unknown" = isInNextAppRouter(filePath) + ? "app" + : isInNextPagesRouter(filePath) + ? "pages" + : "unknown"; + + const isClientComponent = hasUseClientDirective(content); + const hasMetadata = /\bexport\s+(?:const|function)\s+(metadata|generateMetadata)\b/.test(content); + + let kind: "page" | "layout" | "route" | "api" | "unknown" = "unknown"; + if (router === "app") { + if (base.startsWith("page.")) kind = "page"; + else if (base.startsWith("layout.")) kind = "layout"; + else if (base.startsWith("route.")) kind = "route"; + else kind = "unknown"; + } else if (router === "pages") { + if (normalized.includes("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/pages/api/") || normalized.includes("/src/pages/api/")) { + kind = "api"; + } else if (isPagesRouterNonRouteFile(baseName)) { + kind = "unknown"; + } else { + kind = "page"; + } + } + + const routePath = router === "unknown" ? null : computeRoutePath(router, normalized); + + return { router, kind, routePath, isClientComponent, hasMetadata }; +} + +function computeRoutePath(router: "app" | "pages", normalizedFilePath: string): string | null { + const parts = normalizedFilePath.split("/").filter(Boolean); + const routerDir = router === "app" ? "app" : "pages"; + const idx = parts.lastIndexOf(routerDir); + if (idx < 0) return null; + + const routeParts = parts.slice(idx + 1); + if (routeParts.length === 0) return "/"; + + const fileName = routeParts.pop(); + if (!fileName) return "/"; + + const baseName = path.basename(fileName, path.extname(fileName)); + if (router === "pages" && isPagesRouterNonRouteFile(baseName)) { + return null; + } + if (router === "pages" && baseName !== "index") { + routeParts.push(baseName); + } + + const cleaned = routeParts + .filter((seg) => !seg.startsWith("(") && !seg.startsWith("@")) + .map((seg) => (seg === "index" ? "" : seg)) + .filter((seg) => seg.length > 0); + + const route = "/" + cleaned.join("/"); + return route === "/" ? "/" : route.replace(/\/+/g, "/"); +} + +function hasUseClientDirective(content: string): boolean { + // Must be a top-of-file directive. + const match = content.match(/^\s*(['"])use client\1\s*;?/); + return Boolean(match); +} + +function isPagesRouterNonRouteFile(baseName: string): boolean { + // These are framework-managed entrypoints, not route segments. + // https://nextjs.org/docs/pages/building-your-application/routing/custom-app + // https://nextjs.org/docs/pages/building-your-application/routing/custom-document + // https://nextjs.org/docs/pages/building-your-application/routing/custom-error + if (!baseName) return false; + return baseName === "_app" || baseName === "_document" || baseName === "_error" || baseName === "_middleware"; +} + +async function detectNextRouterPresence( + rootPath: string +): Promise<{ hasAppRouter: boolean; hasPagesRouter: boolean }> { + const candidates = [ + path.join(rootPath, "app"), + path.join(rootPath, "src", "app"), + path.join(rootPath, "pages"), + path.join(rootPath, "src", "pages"), + ]; + + const hasAppRouter = await anyExists(candidates.slice(0, 2)); + const hasPagesRouter = await anyExists(candidates.slice(2)); + return { hasAppRouter, hasPagesRouter }; +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +async function anyExists(paths: string[]): Promise { + for (const p of paths) { + if (await exists(p)) return true; + } + return false; +} + +async function tryLoadIndexStatistics(rootPath: string): Promise { + const base: CodebaseMetadata["statistics"] = { + 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, + }, + }; + + try { + const indexPath = path.join(rootPath, ".codebase-index.json"); + const stat = await fs.stat(indexPath); + // Avoid blocking the event loop parsing very large index files. + if (stat.size > 20 * 1024 * 1024) { + return base; + } + const indexContent = await fs.readFile(indexPath, "utf-8"); + const chunks = (await parseJsonInWorker(indexContent)) as any[]; + + if (Array.isArray(chunks) && chunks.length > 0) { + base.totalFiles = new Set(chunks.map((c: any) => c.filePath)).size; + base.totalLines = chunks.reduce( + (sum: number, c: any) => sum + (c.endLine - c.startLine + 1), + 0 + ); + for (const chunk of chunks) { + if (chunk.componentType) { + base.componentsByType[chunk.componentType] = + (base.componentsByType[chunk.componentType] || 0) + 1; + base.totalComponents++; + } + const layer = chunk.layer as unknown; + if (isArchitecturalLayer(layer, base.componentsByLayer)) { + base.componentsByLayer[layer] = (base.componentsByLayer[layer] || 0) + 1; + } + } + } + } catch { + // Index doesn't exist yet + } + + return base; +} + +function isArchitecturalLayer( + layer: unknown, + layers: CodebaseMetadata["statistics"]["componentsByLayer"] +): layer is keyof typeof layers { + return typeof layer === "string" && layer in layers; +} + +function detectStateLibraries(allDeps: Record): string[] { + const state: string[] = []; + if (allDeps["@reduxjs/toolkit"] || allDeps["redux"]) state.push("redux"); + if (allDeps["zustand"]) state.push("zustand"); + if (allDeps["jotai"]) state.push("jotai"); + if (allDeps["recoil"]) state.push("recoil"); + if (allDeps["mobx"]) state.push("mobx"); + return state; +} + +function detectUiLibraries(allDeps: Record): string[] { + const ui: string[] = []; + if (allDeps["tailwindcss"]) ui.push("Tailwind"); + if (allDeps["@mui/material"]) ui.push("MUI"); + if (allDeps["styled-components"]) ui.push("styled-components"); + if (allDeps["@radix-ui/react-slot"] || Object.keys(allDeps).some((d) => d.startsWith("@radix-ui/react-"))) { + ui.push("Radix UI"); + } + return ui; +} + +function detectTestingLibraries(allDeps: Record): string[] { + const test: string[] = []; + if (allDeps["vitest"]) test.push("Vitest"); + if (allDeps["jest"]) test.push("Jest"); + if (allDeps["@testing-library/react"]) test.push("Testing Library"); + if (allDeps["playwright"]) test.push("Playwright"); + if (allDeps["cypress"]) test.push("Cypress"); + return test; +} diff --git a/src/analyzers/orchestration/ecosystem.ts b/src/analyzers/orchestration/ecosystem.ts new file mode 100644 index 0000000..481f05d --- /dev/null +++ b/src/analyzers/orchestration/ecosystem.ts @@ -0,0 +1,133 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { + AnalysisResult, + CodebaseMetadata, + CodeChunk, + FrameworkAnalyzer, +} from "../../types/index.js"; +import { AngularAnalyzer } from "../angular/index.js"; +import { GenericAnalyzer } from "../generic/index.js"; +import { NextJsAnalyzer } from "../nextjs/index.js"; +import { ReactAnalyzer } from "../react/index.js"; +import { + detectEcosystemFromPackageJsons, + readRootPackageJson, + scanWorkspacePackageJsons, +} from "./package-json.js"; + +/** + * Ecosystem Analyzer + * + * This analyzer is intentionally NOT used for per-file analysis. Its job is to + * orchestrate codebase-level metadata detection across multiple frameworks + * (Angular, React, Next.js, etc). + */ +export class EcosystemAnalyzer implements FrameworkAnalyzer { + readonly name = "ecosystem"; + readonly version = "1.0.0"; + readonly supportedExtensions: string[] = []; + readonly priority = 1000; + + canAnalyze(): boolean { + return false; + } + + async analyze(filePath: string, content: string): Promise { + return { + filePath, + language: "unknown", + framework: this.name, + components: [], + imports: [], + exports: [], + dependencies: [], + metadata: { + analyzer: this.name, + skipped: true, + }, + chunks: [] satisfies CodeChunk[], + }; + } + + async detectCodebaseMetadata(rootPath: string): Promise { + const packageJsons = await scanWorkspacePackageJsons(rootPath); + const rootPackage = await readRootPackageJson(rootPath); + if (rootPackage && !packageJsons.some((p) => p.filePath === rootPackage.filePath)) { + packageJsons.unshift(rootPackage); + } + + const ecosystem = detectEcosystemFromPackageJsons(packageJsons); + + // Choose a specific analyzer for deeper metadata (gated by package.json deps). + const analyzer: FrameworkAnalyzer = + ecosystem.primaryFramework === "nextjs" + ? new NextJsAnalyzer() + : ecosystem.primaryFramework === "angular" + ? new AngularAnalyzer() + : ecosystem.primaryFramework === "react" + ? new ReactAnalyzer() + : new GenericAnalyzer(); + + const metadata = await analyzer.detectCodebaseMetadata(rootPath); + + metadata.customMetadata = { + ...metadata.customMetadata, + ecosystem: { + frameworks: ecosystem.frameworks, + primaryFramework: ecosystem.primaryFramework, + versions: ecosystem.frameworkVersions, + tooling: { + hasTypeScript: ecosystem.hasTypeScript, + hasNx: ecosystem.hasNx, + }, + libraries: ecosystem.libraries, + workspacePackageJsonCount: packageJsons.length, + }, + }; + + // Additional lightweight Next.js router detection for convenience. + if (ecosystem.primaryFramework === "nextjs") { + const router = await this.detectNextRouterPresence(rootPath); + metadata.customMetadata = { + ...metadata.customMetadata, + nextjs: router, + }; + } + + return metadata; + } + + private async detectNextRouterPresence( + rootPath: string + ): Promise<{ hasAppRouter: boolean; hasPagesRouter: boolean }> { + const candidates = [ + path.join(rootPath, "app"), + path.join(rootPath, "src", "app"), + path.join(rootPath, "pages"), + path.join(rootPath, "src", "pages"), + ]; + + const hasAppRouter = await anyExists(candidates.slice(0, 2)); + const hasPagesRouter = await anyExists(candidates.slice(2)); + + return { hasAppRouter, hasPagesRouter }; + } +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +async function anyExists(paths: string[]): Promise { + for (const p of paths) { + if (await exists(p)) return true; + } + return false; +} + diff --git a/src/analyzers/orchestration/package-json.ts b/src/analyzers/orchestration/package-json.ts new file mode 100644 index 0000000..4b5f126 --- /dev/null +++ b/src/analyzers/orchestration/package-json.ts @@ -0,0 +1,161 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { glob } from "glob"; + +export interface PackageJson { + name?: string; + version?: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +export interface PackageJsonWithPath { + filePath: string; + packageJson: PackageJson; +} + +export type DetectedFramework = "angular" | "react" | "nextjs"; + +export interface DetectedEcosystem { + frameworks: DetectedFramework[]; + primaryFramework: DetectedFramework | null; + frameworkVersions: Partial>; + hasTypeScript: boolean; + hasNx: boolean; + libraries: { + forms: string[]; + validation: string[]; + state: string[]; + data: string[]; + styling: string[]; + }; +} + +export function normalizePackageVersion(version: string | undefined): string | undefined { + if (!version) return undefined; + return version.replace(/^[~^]/, ""); +} + +export function getPackageName(importSource: string): string { + if (importSource.startsWith("@")) { + const [scope, name] = importSource.split("/"); + return name ? `${scope}/${name}` : importSource; + } + return importSource.split("/")[0] || importSource; +} + +export function mergeDependencies(packageJson: PackageJson): Record { + return { + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}), + ...(packageJson.peerDependencies || {}), + }; +} + +export async function readPackageJson(filePath: string): Promise { + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as PackageJson; +} + +export async function readRootPackageJson(rootPath: string): Promise { + const packageJsonPath = path.join(rootPath, "package.json"); + try { + const packageJson = await readPackageJson(packageJsonPath); + return { filePath: packageJsonPath, packageJson }; + } catch { + return null; + } +} + +export async function scanWorkspacePackageJsons(rootPath: string): Promise { + const matches = await glob(["package.json", "apps/*/package.json", "packages/*/package.json"], { + cwd: rootPath, + absolute: true, + ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"], + nodir: true, + }); + + const uniquePaths = Array.from(new Set(matches)); + const results: PackageJsonWithPath[] = []; + + for (const filePath of uniquePaths) { + try { + results.push({ filePath, packageJson: await readPackageJson(filePath) }); + } catch { + // Ignore unreadable package.json files + } + } + + return results; +} + +function hasAnyDependency(allDeps: Record, names: string[]): boolean { + return names.some((n) => Boolean(allDeps[n])); +} + +export function detectEcosystemFromPackageJsons( + packageJsons: PackageJsonWithPath[] +): DetectedEcosystem { + const allDeps: Record = {}; + for (const { packageJson } of packageJsons) { + Object.assign(allDeps, mergeDependencies(packageJson)); + } + + const hasAngular = Boolean(allDeps["@angular/core"]); + const hasNext = Boolean(allDeps["next"]); + const hasReact = Boolean(allDeps["react"]); + + const frameworks: DetectedFramework[] = []; + if (hasAngular) frameworks.push("angular"); + if (hasNext) frameworks.push("nextjs"); + if (hasReact) frameworks.push("react"); + + const primaryFramework: DetectedFramework | null = hasNext + ? "nextjs" + : hasAngular + ? "angular" + : hasReact + ? "react" + : null; + + const frameworkVersions: Partial> = { + angular: normalizePackageVersion(allDeps["@angular/core"]), + nextjs: normalizePackageVersion(allDeps["next"]), + react: normalizePackageVersion(allDeps["react"]), + }; + + const hasTypeScript = Boolean(allDeps["typescript"]); + + const hasNx = maybeDetectNx(allDeps); + + const libraries = { + forms: detectLibraries(allDeps, ["react-hook-form", "formik", "final-form", "@tanstack/react-form"]), + validation: detectLibraries(allDeps, ["zod", "yup", "joi", "valibot"]), + state: detectLibraries(allDeps, ["@reduxjs/toolkit", "redux", "zustand", "jotai", "recoil", "mobx"]), + data: detectLibraries(allDeps, ["@tanstack/react-query", "@apollo/client", "swr", "urql"]), + styling: detectLibraries(allDeps, ["tailwindcss", "@mui/material", "styled-components", "@emotion/react", "class-variance-authority", "@radix-ui/react-slot"]), + }; + + return { + frameworks, + primaryFramework, + frameworkVersions, + hasTypeScript, + hasNx, + libraries, + }; +} + +function detectLibraries(allDeps: Record, candidates: string[]): string[] { + return candidates.filter((pkg) => Boolean(allDeps[pkg])); +} + +function maybeDetectNx(allDeps: Record): boolean { + // Nx can be indicated by nx.json, workspace.json, or @nx/* deps. We keep it light and dependency-based. + if (Boolean(allDeps["nx"])) return true; + if (Object.keys(allDeps).some((name) => name.startsWith("@nx/"))) return true; + if (Object.keys(allDeps).some((name) => name.startsWith("@nrwl/"))) return true; + return false; +} diff --git a/src/analyzers/react/index.ts b/src/analyzers/react/index.ts new file mode 100644 index 0000000..ce28c00 --- /dev/null +++ b/src/analyzers/react/index.ts @@ -0,0 +1,625 @@ +/** + * React Analyzer - Framework-aware analysis for React projects. + * + * Detects: + * - Function/class components + * - Hook usage and custom hooks + * - Context creation and usage + * - Memoization and Suspense patterns + * + * No runtime execution: filesystem + AST only. + */ + +import path from "path"; +import { + FrameworkAnalyzer, + AnalysisResult, + CodebaseMetadata, + CodeChunk, + CodeComponent, + ImportStatement, + ExportStatement, + Dependency, +} from "../../types/index.js"; +import { parse } from "@typescript-eslint/typescript-estree"; +import { createChunksFromCode } from "../../utils/chunking.js"; +import { parseJsonInWorker } from "../../utils/async-json.js"; +import { + getPackageName, + mergeDependencies, + normalizePackageVersion, + readRootPackageJson, +} from "../orchestration/package-json.js"; +import { promises as fs } from "fs"; + +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", +]); + +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 ext = path.extname(filePath).toLowerCase(); + if (!this.supportedExtensions.includes(ext)) return false; + + // TSX/JSX are overwhelmingly React in practice. + if (ext === ".tsx" || ext === ".jsx") return true; + + if (!content) return false; + + // Heuristics for .ts/.js (non-JSX extensions) + if (/\bfrom\s+["']react["']/.test(content)) return true; + if (/\brequire\(\s*["']react["']\s*\)/.test(content)) return true; + if (/\bReact\.createElement\b/.test(content)) return true; + + // JSX without .tsx/.jsx (rare but possible with tooling) + if (/<[A-Za-z][^>]*>/.test(content)) return true; + + return false; + } + + async analyze(filePath: string, content: string): Promise { + const ext = path.extname(filePath).toLowerCase(); + const isJsx = ext.includes("x"); + const language = ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts" + ? "typescript" + : "javascript"; + const relativePath = path.relative(process.cwd(), filePath); + + const components: CodeComponent[] = []; + const imports: ImportStatement[] = []; + const exports: ExportStatement[] = []; + const dependencies: string[] = []; + + const detectedPatterns: DetectedPattern[] = []; + + try { + const ast = parse(content, { + loc: true, + range: true, + comment: true, + jsx: isJsx, + sourceType: "module", + }); + + // Imports / dependencies + for (const node of ast.body) { + if (node.type === "ImportDeclaration" && node.source.value) { + const source = node.source.value as string; + imports.push({ + source, + imports: node.specifiers.map((s: any) => { + if (s.type === "ImportDefaultSpecifier") return "default"; + if (s.type === "ImportNamespaceSpecifier") return "*"; + return s.imported?.name || s.local?.name || "unknown"; + }), + isDefault: node.specifiers.some( + (s: any) => s.type === "ImportDefaultSpecifier" + ), + isDynamic: false, + line: node.loc?.start.line, + }); + + if (!source.startsWith(".") && !source.startsWith("/")) { + dependencies.push(getPackageName(source)); + } + } + + // Named exports + if (node.type === "ExportNamedDeclaration") { + if (node.declaration?.type === "FunctionDeclaration" && node.declaration.id) { + exports.push({ name: node.declaration.id.name, isDefault: false, type: "function" }); + } else if (node.declaration?.type === "ClassDeclaration" && node.declaration.id) { + exports.push({ name: node.declaration.id.name, isDefault: false, type: "class" }); + } else if (node.declaration?.type === "VariableDeclaration") { + for (const decl of node.declaration.declarations) { + if (decl.id.type === "Identifier") { + exports.push({ name: decl.id.name, isDefault: false, type: "variable" }); + } + } + } else if (node.specifiers && node.specifiers.length > 0) { + for (const s of node.specifiers as any[]) { + if (s.exported?.name) exports.push({ name: s.exported.name, isDefault: false, type: "re-export" }); + } + } + } + + // Default export + if (node.type === "ExportDefaultDeclaration") { + exports.push({ name: "default", isDefault: true, type: "default" }); + } + } + + const analysis = analyzeReactAst(ast); + + // Components / hooks + for (const component of analysis.components) { + components.push(component); + } + + // Patterns + if (analysis.usesContext) { + detectedPatterns.push({ category: "stateManagement", name: "React Context" }); + } + if (analysis.usesSuspense) { + detectedPatterns.push({ category: "reactivity", name: "Suspense" }); + } + if (analysis.usesMemoization) { + detectedPatterns.push({ category: "reactivity", name: "Memoization" }); + } + if (analysis.customHooks.length > 0) { + detectedPatterns.push({ category: "reactHooks", name: "Custom hooks" }); + } + if (analysis.builtinHooksUsed.length > 0) { + detectedPatterns.push({ category: "reactHooks", name: "Built-in hooks" }); + } + + // Library-specific signals (Phase 4-6) + if (analysis.importSources.has("react-hook-form")) { + detectedPatterns.push({ category: "forms", name: "react-hook-form" }); + } + if (analysis.importSources.has("zod")) { + detectedPatterns.push({ category: "validation", name: "zod" }); + } + if (analysis.importSources.has("@tanstack/react-query")) { + detectedPatterns.push({ category: "data", name: "tanstack-query" }); + } + if (analysis.importSources.has("@reduxjs/toolkit")) { + detectedPatterns.push({ category: "stateManagement", name: "redux-toolkit" }); + } + if (analysis.importSources.has("tailwindcss")) { + detectedPatterns.push({ category: "styling", name: "tailwind" }); + } + } catch (error) { + console.warn(`Failed to parse React file ${filePath}:`, error); + } + + const uniqueDependencies = Array.from(new Set(dependencies)).sort(); + + const chunks = await createChunksFromCode( + content, + filePath, + relativePath, + language, + components, + { + framework: "react", + detectedPatterns, + } + ); + + return { + filePath, + language, + framework: "react", + components, + imports, + exports, + dependencies: uniqueDependencies.map((name) => ({ + name, + category: this.categorizeDependency(name), + })), + metadata: { + analyzer: this.name, + detectedPatterns, + }, + chunks, + }; + } + + async detectCodebaseMetadata(rootPath: string): Promise { + const rootPkg = await readRootPackageJson(rootPath); + const packageJson = rootPkg?.packageJson; + + const projectName = packageJson?.name || path.basename(rootPath); + const allDeps = packageJson ? mergeDependencies(packageJson) : {}; + + const reactVersion = normalizePackageVersion(allDeps["react"]) || "unknown"; + + const dependencies: Dependency[] = Object.entries(allDeps).map(([name, version]) => ({ + name, + version: version as string, + category: this.categorizeDependency(name), + })); + + // Basic statistics from existing index if available (same pattern as Angular) + const statistics = await tryLoadIndexStatistics(rootPath); + + return { + name: projectName, + rootPath, + framework: { + name: "React", + version: reactVersion, + type: "react", + variant: "unknown", + stateManagement: detectStateLibraries(allDeps), + uiLibraries: detectUiLibraries(allDeps), + testingFrameworks: detectTestingLibraries(allDeps), + }, + languages: [], + dependencies, + architecture: { + type: "feature-based", + layers: { + presentation: 0, + business: 0, + data: 0, + state: 0, + core: 0, + shared: 0, + feature: 0, + infrastructure: 0, + unknown: 0, + }, + patterns: [], + }, + styleGuides: [], + documentation: [], + projectStructure: { + type: "single-app", + }, + statistics, + customMetadata: {}, + }; + } + + summarize(chunk: CodeChunk): string { + const name = chunk.metadata?.componentName; + const type = chunk.componentType; + const fileName = path.basename(chunk.filePath); + if (name && type) return `${name} (${type}) in ${fileName}.`; + if (name) return `${name} in ${fileName}.`; + return `React code in ${fileName}: lines ${chunk.startLine}-${chunk.endLine}.`; + } + + private categorizeDependency(name: string): Dependency["category"] { + if (name === "react" || name === "react-dom") return "framework"; + if (name === "next") return "framework"; + if (name.startsWith("@reduxjs/") || name === "redux" || name === "zustand") return "state"; + if (name === "@tanstack/react-query" || name === "swr" || name === "@apollo/client") return "http"; + if (name === "tailwindcss" || name === "@mui/material" || name === "styled-components") return "ui"; + if (name === "vitest" || name === "jest" || name === "@testing-library/react") return "testing"; + if (name === "typescript" || name === "eslint" || name === "vite") return "build"; + return "other"; + } +} + +function isComponentName(name: string): boolean { + return /^[A-Z]/.test(name); +} + +function isCustomHookName(name: string): boolean { + return /^use[A-Z0-9]/.test(name); +} + +function analyzeReactAst(ast: any): { + components: CodeComponent[]; + builtinHooksUsed: string[]; + customHooks: string[]; + usesContext: boolean; + usesMemoization: boolean; + usesSuspense: boolean; + importSources: Set; +} { + const components: CodeComponent[] = []; + const builtinHooksUsed = new Set(); + const customHooks = new Set(); + const importSources = new Set(); + + // Collect import sources for library detection + for (const node of ast.body as any[]) { + if (node?.type === "ImportDeclaration" && typeof node.source?.value === "string") { + importSources.add(getPackageName(node.source.value)); + } + } + + let usesContext = false; + let usesMemoization = false; + let usesSuspense = false; + + walkAst(ast, (node, parent) => { + if (!node || typeof node !== "object") return; + + // Hook usage + if (node.type === "CallExpression") { + const hookName = getCalleeName(node.callee); + if (hookName) { + if (BUILTIN_HOOKS.has(hookName)) builtinHooksUsed.add(hookName); + if (hookName === "createContext" || hookName === "useContext") usesContext = true; + if (hookName === "memo" || hookName === "useMemo" || hookName === "useCallback") { + usesMemoization = true; + } + if (hookName === "lazy") { + usesSuspense = true; + } + } + + // Context creation captured as variable name + if (hookName === "createContext" && parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") { + usesContext = true; + } + } + + // JSX Suspense detection + if (node.type === "JSXElement" || node.type === "JSXFragment") { + 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; + } + } + } + + // Component detection (functions, variables, classes) + if (node.type === "FunctionDeclaration" && node.id?.name && node.loc) { + const name = node.id.name as string; + if (isCustomHookName(name)) { + customHooks.add(name); + components.push(toComponent(node, "function", "hook", { reactType: "custom-hook" })); + } else if (isComponentName(name) && containsJsx(node)) { + components.push(toComponent(node, "function", "component", { reactType: "function-component" })); + } + } + + if (node.type === "VariableDeclarator" && node.id?.type === "Identifier" && node.init && node.loc) { + const name = node.id.name as string; + if (node.init.type === "ArrowFunctionExpression" || node.init.type === "FunctionExpression") { + if (isCustomHookName(name)) { + customHooks.add(name); + components.push(toComponent(node, "function", "hook", { reactType: "custom-hook" })); + } else if (isComponentName(name) && containsJsx(node.init)) { + components.push(toComponent(node, "function", "component", { reactType: "function-component" })); + } + } + } + + if (node.type === "ClassDeclaration" && node.id?.name && node.loc) { + const name = node.id.name as string; + if (isComponentName(name) && isReactComponentSuperclass(node.superClass)) { + components.push(toComponent(node, "class", "component", { reactType: "class-component" })); + } + } + }); + + return { + components: dedupeComponents(components), + builtinHooksUsed: Array.from(builtinHooksUsed).sort(), + customHooks: Array.from(customHooks).sort(), + usesContext, + usesMemoization, + usesSuspense, + importSources, + }; +} + +function dedupeComponents(components: CodeComponent[]): CodeComponent[] { + const seen = new Set(); + const result: CodeComponent[] = []; + for (const c of components) { + const key = `${c.name}:${c.startLine}:${c.endLine}:${c.componentType || ""}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(c); + } + return result; +} + +function toComponent( + node: any, + type: string, + componentType: string, + metadata: Record +): CodeComponent { + const startLine = node.loc?.start?.line ?? 1; + const endLine = node.loc?.end?.line ?? startLine; + + const name = + node.type === "VariableDeclarator" ? node.id?.name : + node.id?.name || metadata?.name || "unknown"; + + return { + name, + type, + componentType, + startLine, + endLine, + metadata, + }; +} + +function getCalleeName(callee: any): string | null { + if (!callee) return null; + if (callee.type === "Identifier") return callee.name || null; + if (callee.type === "MemberExpression") { + const objectName = callee.object?.type === "Identifier" ? callee.object.name : null; + const propName = callee.property?.type === "Identifier" ? callee.property.name : null; + if (propName && objectName === "React") return propName; + if (propName) return propName; + } + return null; +} + +function getJsxTagName(nameNode: any): string | null { + if (!nameNode) return null; + if (nameNode.type === "JSXIdentifier") return nameNode.name || null; + if (nameNode.type === "JSXMemberExpression") { + const object = getJsxTagName(nameNode.object); + const prop = getJsxTagName(nameNode.property); + if (object && prop) return `${object}.${prop}`; + } + return null; +} + +function isReactComponentSuperclass(superClass: any): boolean { + if (!superClass) return false; + if (superClass.type === "MemberExpression") { + const objectName = superClass.object?.type === "Identifier" ? superClass.object.name : null; + const propName = superClass.property?.type === "Identifier" ? superClass.property.name : null; + return objectName === "React" && (propName === "Component" || propName === "PureComponent"); + } + if (superClass.type === "Identifier") { + return superClass.name === "Component" || superClass.name === "PureComponent"; + } + return false; +} + +function containsJsx(node: any): boolean { + let found = false; + walkAst(node, (n) => { + if (found) return; + if (n?.type === "JSXElement" || n?.type === "JSXFragment") found = true; + }); + return found; +} + +function walkAst(root: any, visitor: (node: any, parent: any | null) => void): void { + const stack: Array<{ node: any; parent: any | null }> = [{ node: root, parent: null }]; + const seen = new Set(); + + while (stack.length > 0) { + const { node, parent } = stack.pop()!; + if (!node || typeof node !== "object") continue; + if (seen.has(node)) continue; + seen.add(node); + + visitor(node, parent); + + for (const value of Object.values(node)) { + if (!value) continue; + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child === "object") { + stack.push({ node: child, parent: node }); + } + } + } else if (typeof value === "object") { + stack.push({ node: value, parent: node }); + } + } + } +} + +async function tryLoadIndexStatistics(rootPath: string): Promise { + const base: CodebaseMetadata["statistics"] = { + 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, + }, + }; + + try { + const indexPath = path.join(rootPath, ".codebase-index.json"); + const stat = await fs.stat(indexPath); + // Avoid blocking the event loop parsing very large index files. + if (stat.size > 20 * 1024 * 1024) { + return base; + } + const indexContent = await fs.readFile(indexPath, "utf-8"); + const chunks = (await parseJsonInWorker(indexContent)) as any[]; + + if (Array.isArray(chunks) && chunks.length > 0) { + base.totalFiles = new Set(chunks.map((c: any) => c.filePath)).size; + base.totalLines = chunks.reduce( + (sum: number, c: any) => sum + (c.endLine - c.startLine + 1), + 0 + ); + for (const chunk of chunks) { + if (chunk.componentType) { + base.componentsByType[chunk.componentType] = + (base.componentsByType[chunk.componentType] || 0) + 1; + base.totalComponents++; + } + const layer = chunk.layer as unknown; + if (isArchitecturalLayer(layer, base.componentsByLayer)) { + base.componentsByLayer[layer] = (base.componentsByLayer[layer] || 0) + 1; + } + } + } + } catch { + // Index doesn't exist yet + } + + return base; +} + +function isArchitecturalLayer( + layer: unknown, + layers: CodebaseMetadata["statistics"]["componentsByLayer"] +): layer is keyof typeof layers { + return typeof layer === "string" && layer in layers; +} + +function detectStateLibraries(allDeps: Record): string[] { + const state: string[] = []; + if (allDeps["@reduxjs/toolkit"] || allDeps["redux"]) state.push("redux"); + if (allDeps["zustand"]) state.push("zustand"); + if (allDeps["jotai"]) state.push("jotai"); + if (allDeps["recoil"]) state.push("recoil"); + if (allDeps["mobx"]) state.push("mobx"); + return state; +} + +function detectUiLibraries(allDeps: Record): string[] { + const ui: string[] = []; + if (allDeps["tailwindcss"]) ui.push("Tailwind"); + if (allDeps["@mui/material"]) ui.push("MUI"); + if (allDeps["styled-components"]) ui.push("styled-components"); + if (allDeps["@radix-ui/react-slot"] || Object.keys(allDeps).some((d) => d.startsWith("@radix-ui/react-"))) { + ui.push("Radix UI"); + } + return ui; +} + +function detectTestingLibraries(allDeps: Record): string[] { + const test: string[] = []; + if (allDeps["vitest"]) test.push("Vitest"); + if (allDeps["jest"]) test.push("Jest"); + if (allDeps["@testing-library/react"]) test.push("Testing Library"); + if (allDeps["playwright"]) test.push("Playwright"); + if (allDeps["cypress"]) test.push("Cypress"); + return test; +} diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 7d1df1d..d32c745 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -622,19 +622,50 @@ export class CodebaseIndexer { stateManagement: { 'RxJS': /BehaviorSubject|ReplaySubject|Subject|Observable/, 'Signals': /\bsignal\s*[<(]/, + 'React Context': /\bcreateContext\s*\(|\buseContext\s*\(/, + 'redux-toolkit': /@reduxjs\/toolkit|configureStore|createSlice/, }, reactivity: { 'Effect': /\beffect\s*\(/, 'Computed': /\bcomputed\s*[<(]/, + 'Suspense': / { }); async function main() { - console.error("Codebase Context MCP Server"); - console.error(`Root: ${ROOT_PATH}`); - console.error( + const shouldLogStartup = ["1", "true", "yes"].includes( + (process.env.CODEBASE_CONTEXT_DEBUG ?? "").toLowerCase() + ); + + const logStartup = (...args: unknown[]) => { + if (shouldLogStartup) console.error(...args); + }; + + logStartup("Codebase Context MCP Server"); + logStartup(`Root: ${ROOT_PATH}`); + logStartup( `Analyzers: ${analyzerRegistry .getAll() .map((a) => a.name) @@ -1018,20 +1031,18 @@ async function main() { // Check for package.json to confirm it's a project root try { await fs.access(path.join(ROOT_PATH, "package.json")); - console.error(`Project detected: ${path.basename(ROOT_PATH)}`); + logStartup(`Project detected: ${path.basename(ROOT_PATH)}`); } catch { - console.error( - `WARNING: No package.json found. This may not be a project root.` - ); + logStartup(`WARNING: No package.json found. This may not be a project root.`); } const needsIndex = await shouldReindex(); if (needsIndex) { - console.error("Starting indexing..."); + logStartup("Starting indexing..."); performIndexing(); } else { - console.error("Index found. Ready."); + logStartup("Index found. Ready."); indexState.status = "ready"; indexState.lastIndexed = new Date(); } @@ -1039,7 +1050,7 @@ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); - console.error("Server ready"); + logStartup("Server ready"); } // Export server components for programmatic use diff --git a/src/lib.ts b/src/lib.ts index 2ab886c..2478385 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -62,6 +62,9 @@ export { // Framework analyzers export { AngularAnalyzer } from "./analyzers/angular/index.js"; export { GenericAnalyzer } from "./analyzers/generic/index.js"; +export { ReactAnalyzer } from "./analyzers/react/index.js"; +export { NextJsAnalyzer } from "./analyzers/nextjs/index.js"; +export { EcosystemAnalyzer } from "./analyzers/orchestration/ecosystem.js"; // Utilities export { diff --git a/src/types/index.ts b/src/types/index.ts index ceff4ec..cb21b86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -195,7 +195,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[]; diff --git a/src/utils/async-json.ts b/src/utils/async-json.ts new file mode 100644 index 0000000..ad3e82d --- /dev/null +++ b/src/utils/async-json.ts @@ -0,0 +1,49 @@ +import { Worker } from "node:worker_threads"; + +/** + * Parse JSON off the main thread to avoid blocking the event loop. + * + * Note: This still loads the full JSON string in memory. It primarily addresses + * main-thread stalls from `JSON.parse` for larger payloads. + */ +export async function parseJsonInWorker(json: string): Promise { + return await new Promise((resolve, reject) => { + const worker = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + try { + const value = JSON.parse(workerData); + parentPort.postMessage({ ok: true, value }); + } catch (error) { + parentPort.postMessage({ + ok: false, + error: error && error.message ? error.message : String(error), + }); + } + `, + { eval: true, workerData: json } + ); + + const cleanup = () => { + worker.removeAllListeners(); + void worker.terminate(); + }; + + worker.once("message", (msg: any) => { + cleanup(); + if (msg?.ok) resolve(msg.value as T); + else reject(new Error(msg?.error || "Failed to parse JSON")); + }); + worker.once("error", (err) => { + cleanup(); + reject(err); + }); + worker.once("exit", (code) => { + if (code !== 0) { + cleanup(); + reject(new Error(`JSON parse worker exited with code ${code}`)); + } + }); + }); +} + From 87a9bb70064afd65871ad5f6408d438eb3ab8e13 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 13:23:28 -0600 Subject: [PATCH 3/6] - test(analyzers): add vitest coverage for React/Next.js analyzers --- package.json | 17 ++- tests/nextjs-analyzer.in-depth.test.ts | 159 +++++++++++++++++++++++++ tests/nextjs-analyzer.test.ts | 50 ++++++++ tests/react-analyzer.in-depth.test.ts | 94 +++++++++++++++ tests/react-analyzer.test.ts | 38 ++++++ vitest.config.ts | 9 ++ 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 tests/nextjs-analyzer.in-depth.test.ts create mode 100644 tests/nextjs-analyzer.test.ts create mode 100644 tests/react-analyzer.in-depth.test.ts create mode 100644 tests/react-analyzer.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 723fd04..90e1fed 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" @@ -75,7 +83,12 @@ "prepublishOnly": "npm run build", "start": "node dist/index.js", "dev": "ts-node src/index.ts", - "watch": "tsc -w" + "test": "vitest run", + "watch": "tsc -w", + "smoke": "vitest run tests/nextjs-analyzer.test.ts tests/react-analyzer.test.ts", + "smoke:nextjs": "vitest run tests/nextjs-analyzer.test.ts", + "smoke:react": "vitest run tests/react-analyzer.test.ts", + "smoke:build": "npm run build && npm run smoke" }, "dependencies": { "@modelcontextprotocol/sdk": "^0.6.0", @@ -95,4 +108,4 @@ "ts-node": "^10.9.2", "vitest": "^1.3.0" } -} \ No newline at end of file +} diff --git a/tests/nextjs-analyzer.in-depth.test.ts b/tests/nextjs-analyzer.in-depth.test.ts new file mode 100644 index 0000000..cd5885e --- /dev/null +++ b/tests/nextjs-analyzer.in-depth.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import path from "path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { NextJsAnalyzer } from "../src/analyzers/nextjs/index.js"; + +describe("NextJsAnalyzer (in-depth)", () => { + 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("computes App Router route paths and ignores route groups/parallel segments", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "app", "(marketing)", "@modal", "settings", "page.tsx"); + + const code = ` +export default function Page() { return
; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.metadata?.nextjs?.router).toBe("app"); + expect(result.metadata?.nextjs?.kind).toBe("page"); + expect(result.metadata?.nextjs?.routePath).toBe("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/settings"); + }); + + it("computes App Router dynamic route paths from folders", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "app", "blog", "[id]", "page.tsx"); + + const code = ` +export default function Page() { return
; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.metadata?.nextjs?.router).toBe("app"); + expect(result.metadata?.nextjs?.routePath).toBe("/blog/[id]"); + }); + + it("computes Pages Router index and API route paths", async () => { + const analyzer = new NextJsAnalyzer(); + + const pagesIndexPath = path.join(process.cwd(), "pages", "blog", "index.tsx"); + const apiPath = path.join(process.cwd(), "pages", "api", "health.ts"); + + const code = `export default function Page() { return null; }`; + + const pagesIndex = await analyzer.analyze(pagesIndexPath, code); + expect(pagesIndex.metadata?.nextjs?.router).toBe("pages"); + expect(pagesIndex.metadata?.nextjs?.kind).toBe("page"); + expect(pagesIndex.metadata?.nextjs?.routePath).toBe("/blog"); + + const api = await analyzer.analyze(apiPath, code); + expect(api.metadata?.nextjs?.router).toBe("pages"); + expect(api.metadata?.nextjs?.kind).toBe("api"); + expect(api.metadata?.nextjs?.routePath).toBe("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/api/health"); + }); + + it("requires \"use client\" as a top-of-file directive", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "app", "settings", "page.tsx"); + + const topOfFile = ` + "use client"; +export default function Page() { return
; } +`; + const notTopOfFile = ` +import React from "react"; +"use client"; +export default function Page() { return
; } +`; + + const ok = await analyzer.analyze(filePath, topOfFile); + expect(ok.metadata?.nextjs?.isClientComponent).toBe(true); + + const notOk = await analyzer.analyze(filePath, notTopOfFile); + expect(notOk.metadata?.nextjs?.isClientComponent).toBe(false); + }); + + it("detects metadata exports (metadata / generateMetadata)", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "app", "settings", "layout.tsx"); + + const code = ` +export const metadata = { title: "Settings" }; +export async function generateMetadata() { return { title: "Settings 2" }; } +export default function Layout({ children }: { children: React.ReactNode }) { return <>{children}; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.metadata?.nextjs?.router).toBe("app"); + expect(result.metadata?.nextjs?.kind).toBe("layout"); + expect(result.metadata?.nextjs?.hasMetadata).toBe(true); + }); + + it("detectCodebaseMetadata infers router variant and loads index statistics", async () => { + const analyzer = new NextJsAnalyzer(); + + const tmpRoot = path.join(process.cwd(), "tests", ".tmp", `nextjs-${randomUUID()}`); + await mkdir(tmpRoot, { recursive: true }); + + try { + await writeFile( + path.join(tmpRoot, "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(tmpRoot, "app"), { recursive: true }); + await mkdir(path.join(tmpRoot, "pages"), { recursive: true }); + + await writeFile( + path.join(tmpRoot, ".codebase-index.json"), + JSON.stringify( + [ + { 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(tmpRoot); + expect(metadata.framework.type).toBe("nextjs"); + expect(metadata.framework.variant).toBe("hybrid"); + expect(metadata.framework.version).toBe("14.1.0"); + expect(metadata.framework.uiLibraries).toContain("Tailwind"); + expect(metadata.framework.stateManagement).toContain("zustand"); + expect(metadata.framework.testingFrameworks).toContain("Vitest"); + + expect(metadata.statistics.totalFiles).toBe(2); + expect(metadata.statistics.totalLines).toBe(15); + expect(metadata.statistics.totalComponents).toBe(2); + expect(metadata.statistics.componentsByType["page"]).toBe(1); + expect(metadata.statistics.componentsByType["api"]).toBe(1); + } finally { + await rm(tmpRoot, { recursive: true, force: true }); + } + }); +}); + diff --git a/tests/nextjs-analyzer.test.ts b/tests/nextjs-analyzer.test.ts new file mode 100644 index 0000000..ddb4b61 --- /dev/null +++ b/tests/nextjs-analyzer.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import path from "path"; +import { NextJsAnalyzer } from "../src/analyzers/nextjs/index.js"; + +describe("NextJsAnalyzer", () => { + it("detects App Router page routing and \"use client\"", 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?.router).toBe("app"); + expect(result.metadata?.nextjs?.routePath).toBe("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/settings"); + expect(result.metadata?.nextjs?.isClientComponent).toBe(true); + expect(result.metadata?.nextjs?.hasMetadata).toBe(true); + }); + + it("computes Pages Router dynamic route paths from filenames", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "pages", "blog", "[id].tsx"); + + const code = ` +export default function BlogPost() { return null; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.metadata?.nextjs?.router).toBe("pages"); + expect(result.metadata?.nextjs?.routePath).toBe("/blog/[id]"); + }); + + it("does not treat _app/_document/_error as routes in Pages Router", async () => { + const analyzer = new NextJsAnalyzer(); + const filePath = path.join(process.cwd(), "pages", "_app.tsx"); + + const code = ` +export default function App() { return null; } +`; + + const result = await analyzer.analyze(filePath, code); + expect(result.metadata?.nextjs?.router).toBe("pages"); + expect(result.metadata?.nextjs?.routePath).toBe(null); + expect(result.metadata?.nextjs?.kind).toBe("unknown"); + }); +}); diff --git a/tests/react-analyzer.in-depth.test.ts b/tests/react-analyzer.in-depth.test.ts new file mode 100644 index 0000000..81316f5 --- /dev/null +++ b/tests/react-analyzer.in-depth.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import path from "path"; +import { ReactAnalyzer } from "../src/analyzers/react/index.js"; + +describe("ReactAnalyzer (in-depth)", () => { + it("canAnalyze uses content 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); + expect(analyzer.canAnalyze("/tmp/file.ts", `const el =
;`)).toBe(true); + }); + + it("detects multiple component styles, hooks, and library signals", async () => { + const analyzer = new ReactAnalyzer(); + const filePath = path.join(process.cwd(), "src", "components", "DeepWidget.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 use2FA() { + const [x] = useState(0); + return x; +} + +export function NotAComponent() { + return null; +} + +export const ArrowWidget = () => { + const theme = useContext(ThemeContext); + const v = useMemo(() => theme, [theme]); + return ( + + {v} + + ); +}; + +export class LegacyWidget extends Component { + render() { + return
; + } +} + +export function UsesLibs() { + useForm(); + z.string(); + useQuery({ queryKey: ["k"], queryFn: async () => 1 }); + configureStore({ reducer: {} }); + return
; +} +`; + + const result = await analyzer.analyze(filePath, code); + + expect(result.framework).toBe("react"); + expect(result.components.some((c) => c.componentType === "hook" && c.name === "use2FA")).toBe(true); + expect(result.components.some((c) => c.componentType === "component" && c.name === "ArrowWidget")).toBe(true); + expect(result.components.some((c) => c.componentType === "component" && c.name === "LegacyWidget")).toBe(true); + expect(result.components.some((c) => c.name === "NotAComponent")).toBe(false); + + const patterns = (result.metadata?.detectedPatterns || []) as Array<{ category: string; name: string }>; + expect(patterns.some((p) => p.category === "stateManagement" && p.name === "React Context")).toBe(true); + expect(patterns.some((p) => p.category === "reactivity" && p.name === "Suspense")).toBe(true); + expect(patterns.some((p) => p.category === "reactivity" && p.name === "Memoization")).toBe(true); + expect(patterns.some((p) => p.category === "reactHooks" && p.name === "Custom hooks")).toBe(true); + expect(patterns.some((p) => p.category === "reactHooks" && p.name === "Built-in hooks")).toBe(true); + + expect(patterns.some((p) => p.category === "forms" && p.name === "react-hook-form")).toBe(true); + expect(patterns.some((p) => p.category === "validation" && p.name === "zod")).toBe(true); + expect(patterns.some((p) => p.category === "data" && p.name === "tanstack-query")).toBe(true); + expect(patterns.some((p) => p.category === "stateManagement" && p.name === "redux-toolkit")).toBe(true); + expect(patterns.some((p) => p.category === "styling" && p.name === "tailwind")).toBe(true); + + const byName = new Map(result.dependencies.map((d) => [d.name, d.category] as const)); + expect(byName.get("react")).toBe("framework"); + expect(byName.get("@reduxjs/toolkit")).toBe("state"); + expect(byName.get("@tanstack/react-query")).toBe("http"); + expect(byName.get("zod")).toBe("other"); + expect(byName.get("react-hook-form")).toBe("other"); + expect(byName.get("tailwindcss")).toBe("ui"); + }); +}); + diff --git a/tests/react-analyzer.test.ts b/tests/react-analyzer.test.ts new file mode 100644 index 0000000..1288eb2 --- /dev/null +++ b/tests/react-analyzer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import path from "path"; +import { ReactAnalyzer } from "../src/analyzers/react/index.js"; + +describe("ReactAnalyzer", () => { + it("detects function components, custom hooks, and context usage", async () => { + const analyzer = new ReactAnalyzer(); + const filePath = path.join(process.cwd(), "src", "components", "MyWidget.tsx"); + + const code = ` +import React, { createContext, useContext, useEffect, useMemo } from "react"; + +export const ThemeContext = createContext("light"); + +export function useTheme() { + return useContext(ThemeContext); +} + +export function MyWidget() { + const theme = useTheme(); + useEffect(() => {}, []); + const v = useMemo(() => theme, [theme]); + return
{v}
; +} +`; + + const result = await analyzer.analyze(filePath, code); + + expect(result.framework).toBe("react"); + expect(result.components.some((c) => c.componentType === "component" && c.name === "MyWidget")).toBe(true); + expect(result.components.some((c) => c.componentType === "hook" && c.name === "useTheme")).toBe(true); + + const patterns = (result.metadata?.detectedPatterns || []) as Array<{ category: string; name: string }>; + expect(patterns.some((p) => p.category === "stateManagement" && p.name === "React Context")).toBe(true); + expect(patterns.some((p) => p.category === "reactivity" && p.name === "Memoization")).toBe(true); + }); +}); + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cffe9e9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +}); + From 8d79ae183f72b0882585790db357ec5a79048eea Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 20:31:56 -0600 Subject: [PATCH 4/6] - chore(ccrn): prepended README.md with updates made with the analyzers --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 106e077..8e19604 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ +# codebase-context-react-nextjs (ReactJS/NextJS/+) + +This branch extends the original `codebase-context` MCP server with **React** and **Next.js** analyzers (while keeping Angular support) so agents can produce higher-signal, framework-aware answers across mixed codebases. + +**What’s new in this fork** +- **Ecosystem detection**: package.json-driven detection of Angular / React / Next.js and common libraries (forms, validation, data, state, styling). +- **React analyzer**: detects components (function + class), built-in/custom hooks, Context usage, memoization, and Suspense patterns. +- **Next.js analyzer**: detects App Router vs Pages Router, route kinds (`page`/`layout`/`route`/`api`), route paths, `"use client"`, and metadata exports. +- **Quieter startup by default**: set `CODEBASE_CONTEXT_DEBUG=1` only when you want startup/index logs. + +## Quick Start (GitHub branch) + +Add this to your MCP client config (Claude Desktop, VS Code, Cursor, etc.). + +```json +"mcpServers": { + "codebase-context": { + "command": "npx", + "args": [ + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" + ] + } +} +``` + +--- + # codebase-context **AI coding agents don't know your codebase. This MCP fixes that.** @@ -12,7 +41,11 @@ Add this to your MCP client config (Claude Desktop, VS Code, Cursor, etc.). "mcpServers": { "codebase-context": { "command": "npx", - "args": ["codebase-context", "/path/to/your/project"] + "args": [ + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" + ] } } ``` From a6477e810e38c1f069928f4ff0b9da404cae882a Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 20:49:43 -0600 Subject: [PATCH 5/6] chore(ccrn): Added smoke test script examples, and added other program specific MCP config examples --- .gitignore | 1 + README.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 474ef6b..62b902c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist/ internal-docs/ internal-docs.zip .codebase-intelligence.json +.idea diff --git a/README.md b/README.md index 8e19604..691c064 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# codebase-context-react-nextjs (ReactJS/NextJS/+) +# Codebase Context (ReactJS/NextJS/+) This branch extends the original `codebase-context` MCP server with **React** and **Next.js** analyzers (while keeping Angular support) so agents can produce higher-signal, framework-aware answers across mixed codebases. @@ -10,11 +10,32 @@ This branch extends the original `codebase-context` MCP server with **React** an ## Quick Start (GitHub branch) -Add this to your MCP client config (Claude Desktop, VS Code, Cursor, etc.). +### Claude Desktop / VS Code / Cursor + +Add this to your MCP client config: ```json -"mcpServers": { - "codebase-context": { +{ + "mcpServers": { + "codebase-context-react-nextjs": { + "command": "npx", + "args": [ + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" + ] + } + } +} +``` + +### Warp + +Add this to your Warp MCP configuration: + +```json +{ + "codebase-context-react-nextjs": { "command": "npx", "args": [ "-y", @@ -25,6 +46,69 @@ Add this to your MCP client config (Claude Desktop, VS Code, Cursor, etc.). } ``` +### Codex + +Add this to your Codex MCP configuration: + +```toml +[mcp_servers.codebase-context-react-nextjs] +command = "npx" +args = [ + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" +] +``` + +### Gemini + +Add this to your Gemini MCP configuration: + +```json +{ + "codebase-context-react-nextjs": { + "command": "npx", + "args": [ + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" + ] + } +} +``` + +### OpenCode + +Add this to your OpenCode MCP configuration: + +```json +{ + "mcp": { + "codebase-context-react-nextjs": { + "type": "local", + "command": [ + "npx", + "-y", + "github:aolin480/codebase-context-react-nextjs#feature/reactjs-nextjs-analyzers", + "/path/to/your/project" + ], + "enabled": true + } + } +} +``` + +## Smoke Tests + +From this repo root: + +```bash +npm run smoke:build +npm run smoke +npm run smoke:nextjs +npm run smoke:react +``` + --- # codebase-context From e9d9080bb8bf7d03ecd9cfa1d3183fd3d0cb645e Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Tue, 30 Dec 2025 20:50:58 -0600 Subject: [PATCH 6/6] congrats(PatrickSys): Big ups! --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 691c064..1bc7ad5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +## Props to PatrickSys for his work on the original codebase-context MCP. I needed to extend it for React/NextJS +https://github.com/PatrickSys/codebase-context + # Codebase Context (ReactJS/NextJS/+) This branch extends the original `codebase-context` MCP server with **React** and **Next.js** analyzers (while keeping Angular support) so agents can produce higher-signal, framework-aware answers across mixed codebases.