From 815fe3a9e320a68a9179f041e78f391319402591 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Sun, 5 Jul 2026 02:33:25 -0500 Subject: [PATCH 1/2] fix(analyzers): Recognize current React and Next patterns React 19 and Next 16 added framework signals that the analyzers were not classifying yet. Keep the analyzer surface current without touching core search or indexing behavior. --- src/analyzers/nextjs/index.ts | 79 +++++++++++++++++++++++++++-------- src/analyzers/react/index.ts | 38 ++++++++++++++++- tests/nextjs-analyzer.test.ts | 34 +++++++++++++++ tests/react-analyzer.test.ts | 28 +++++++++++++ 4 files changed, 159 insertions(+), 20 deletions(-) diff --git a/src/analyzers/nextjs/index.ts b/src/analyzers/nextjs/index.ts index 79fa6c5..ea99dab 100644 --- a/src/analyzers/nextjs/index.ts +++ b/src/analyzers/nextjs/index.ts @@ -21,8 +21,35 @@ import { } from '../shared/metadata.js'; type DetectedPattern = { category: string; name: string }; -type NextRouter = 'app' | 'pages' | 'unknown'; -type NextRouteKind = 'page' | 'layout' | 'route' | 'api' | 'unknown'; +type NextRouter = 'app' | 'pages' | 'proxy' | 'unknown'; +type NextRouteKind = + | 'page' + | 'layout' + | 'route' + | 'api' + | 'loading' + | 'error' + | 'not-found' + | 'template' + | 'default' + | 'forbidden' + | 'unauthorized' + | 'proxy' + | 'unknown'; + +const APP_ROUTE_FILE_KINDS: Readonly> = { + page: 'page', + layout: 'layout', + route: 'route', + loading: 'loading', + error: 'error', + 'global-error': 'error', + 'not-found': 'not-found', + template: 'template', + default: 'default', + forbidden: 'forbidden', + unauthorized: 'unauthorized' +}; interface NextRoutingInfo { router: NextRouter; @@ -44,7 +71,7 @@ export class NextJsAnalyzer implements FrameworkAnalyzer { return false; } - if (isInAppRouter(filePath) || isInPagesRouter(filePath)) { + if (isInAppRouter(filePath) || isInPagesRouter(filePath) || isProxyFile(filePath)) { return true; } @@ -83,6 +110,9 @@ export class NextJsAnalyzer implements FrameworkAnalyzer { if (routing.kind === 'api') { detectedPatterns.push({ category: 'routing', name: 'API Route' }); } + if (routing.kind === 'proxy') { + detectedPatterns.push({ category: 'routing', name: 'Proxy' }); + } if (routing.hasMetadata) { detectedPatterns.push({ category: 'metadata', name: 'Next.js metadata' }); } @@ -276,21 +306,22 @@ export class NextJsAnalyzer implements FrameworkAnalyzer { 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'; + const router: NextRouter = isProxyFile(filePath) + ? 'proxy' + : isInAppRouter(filePath) + ? 'app' + : isInPagesRouter(filePath) + ? 'pages' + : 'unknown'; let kind: NextRouteKind = 'unknown'; - if (router === 'app') { + if (router === 'proxy') { + kind = 'proxy'; + } else 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'; + const appFileKind = getAppRouteFileKind(fileName); + if (appFileKind) { + kind = appFileKind; } } else if (router === 'pages') { if (normalizedPath.includes('/pages/api/') || normalizedPath.includes('/src/pages/api/')) { @@ -303,9 +334,11 @@ function analyzeRouting(filePath: string, content: string): NextRoutingInfo { return { router, kind, - routePath: router === 'unknown' ? null : computeRoutePath(router, normalizedPath), + routePath: + router === 'app' || router === 'pages' ? computeRoutePath(router, normalizedPath) : null, isClientComponent: hasUseClientDirective(content), - hasMetadata: /\bexport\s+(?:const|function)\s+(metadata|generateMetadata)\b/.test(content) + hasMetadata: + /\bexport\s+(?:const|async\s+function|function)\s+(metadata|generateMetadata)\b/.test(content) }; } @@ -320,7 +353,7 @@ function isInPagesRouter(filePath: string): boolean { } function computeRoutePath( - router: Exclude, + router: Extract, normalizedFilePath: string ): string | null { const pathSegments = normalizedFilePath.split('/').filter(Boolean); @@ -361,6 +394,16 @@ function isPagesSystemFile(baseName: string): boolean { return ['_app', '_document', '_error', '_middleware'].includes(baseName); } +function getAppRouteFileKind(fileName: string): NextRouteKind | null { + const baseName = path.basename(fileName, path.extname(fileName)); + return APP_ROUTE_FILE_KINDS[baseName] || null; +} + +function isProxyFile(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + return /(^|\/)(src\/)?proxy\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/.test(normalizedPath); +} + async function detectRouterPresence( rootPath: string ): Promise<{ hasAppRouter: boolean; hasPagesRouter: boolean }> { diff --git a/src/analyzers/react/index.ts b/src/analyzers/react/index.ts index 317d296..2ebd29d 100644 --- a/src/analyzers/react/index.ts +++ b/src/analyzers/react/index.ts @@ -37,7 +37,11 @@ const BUILTIN_HOOKS = new Set([ 'useTransition', 'useId', 'useSyncExternalStore', - 'useInsertionEffect' + 'useInsertionEffect', + 'use', + 'useActionState', + 'useOptimistic', + 'useFormStatus' ]); const REACT_LIBRARY_SIGNALS: ReadonlyArray<{ @@ -59,6 +63,9 @@ interface ReactAstSummary { usesContext: boolean; usesMemoization: boolean; usesSuspense: boolean; + usesActionState: boolean; + usesOptimistic: boolean; + usesFormStatus: boolean; } export class ReactAnalyzer implements FrameworkAnalyzer { @@ -144,6 +151,15 @@ export class ReactAnalyzer implements FrameworkAnalyzer { if (summary.usesSuspense) { detectedPatterns.push({ category: 'reactivity', name: 'Suspense' }); } + if (summary.usesActionState) { + detectedPatterns.push({ category: 'reactivity', name: 'Actions' }); + } + if (summary.usesOptimistic) { + detectedPatterns.push({ category: 'reactivity', name: 'Optimistic UI' }); + } + if (summary.usesFormStatus) { + detectedPatterns.push({ category: 'forms', name: 'Form status' }); + } if (summary.usesMemoization) { detectedPatterns.push({ category: 'reactivity', name: 'Memoization' }); } @@ -309,6 +325,9 @@ function summarizeReactProgram(program: TSESTree.Program): ReactAstSummary { let usesContext = false; let usesMemoization = false; let usesSuspense = false; + let usesActionState = false; + let usesOptimistic = false; + let usesFormStatus = false; walkAst(program, (node, parent) => { if (node.type === 'CallExpression') { @@ -325,6 +344,18 @@ function summarizeReactProgram(program: TSESTree.Program): ReactAstSummary { if (calleeName === 'lazy') { usesSuspense = true; } + if (calleeName === 'use') { + usesSuspense = true; + } + if (calleeName === 'useActionState') { + usesActionState = true; + } + if (calleeName === 'useOptimistic') { + usesOptimistic = true; + } + if (calleeName === 'useFormStatus') { + usesFormStatus = true; + } if ( calleeName === 'createContext' && parent?.type === 'VariableDeclarator' && @@ -390,7 +421,10 @@ function summarizeReactProgram(program: TSESTree.Program): ReactAstSummary { customHooks: Array.from(customHooks).sort(), usesContext, usesMemoization, - usesSuspense + usesSuspense, + usesActionState, + usesOptimistic, + usesFormStatus }; } diff --git a/tests/nextjs-analyzer.test.ts b/tests/nextjs-analyzer.test.ts index 1882dbb..4f6d9e6 100644 --- a/tests/nextjs-analyzer.test.ts +++ b/tests/nextjs-analyzer.test.ts @@ -59,6 +59,40 @@ export default function Page() { return
; } }); }); + it('classifies current App Router file conventions and proxy files', async () => { + const analyzer = new NextJsAnalyzer(); + + const loading = await analyzer.analyze( + path.join(process.cwd(), 'app', 'dashboard', 'loading.tsx'), + 'export default function Loading() { return
; }' + ); + expect(loading.metadata.nextjs).toMatchObject({ + router: 'app', + kind: 'loading', + routePath: '/dashboard' + }); + + const notFound = await analyzer.analyze( + path.join(process.cwd(), 'src', 'app', 'shop', 'not-found.tsx'), + 'export default function NotFound() { return
; }' + ); + expect(notFound.metadata.nextjs).toMatchObject({ + router: 'app', + kind: 'not-found', + routePath: '/shop' + }); + + const proxy = await analyzer.analyze( + path.join(process.cwd(), 'src', 'proxy.ts'), + 'export function proxy() { return null; }' + ); + expect(proxy.metadata.nextjs).toMatchObject({ + router: 'proxy', + kind: 'proxy', + routePath: null + }); + }); + 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( diff --git a/tests/react-analyzer.test.ts b/tests/react-analyzer.test.ts index b3b9ef4..5728637 100644 --- a/tests/react-analyzer.test.ts +++ b/tests/react-analyzer.test.ts @@ -80,6 +80,34 @@ export class LegacyWidget extends Component { expect(patterns).toContainEqual({ category: 'styling', name: 'tailwind' }); }); + it('detects React 19 action and optimistic hooks', async () => { + const analyzer = new ReactAnalyzer(); + const filePath = path.join(process.cwd(), 'src', 'components', 'CheckoutForm.tsx'); + + const code = ` +import { use, useActionState, useOptimistic } from "react"; +import { useFormStatus } from "react-dom"; + +export function CheckoutForm({ cartPromise }: { cartPromise: Promise }) { + const cart = use(cartPromise); + const [state, submitAction] = useActionState(async () => ({ ok: true }), { ok: false }); + const [optimisticCart] = useOptimistic(cart); + const status = useFormStatus(); + + return
{state.ok && optimisticCart.length && status.pending}
; +} +`; + + const result = await analyzer.analyze(filePath, code); + const patterns = (result.metadata.detectedPatterns || []) as Array<{ category: string; name: string }>; + + expect(patterns).toContainEqual({ category: 'reactHooks', name: 'Built-in hooks' }); + expect(patterns).toContainEqual({ category: 'reactivity', name: 'Actions' }); + expect(patterns).toContainEqual({ category: 'reactivity', name: 'Optimistic UI' }); + expect(patterns).toContainEqual({ category: 'forms', name: 'Form status' }); + expect(patterns).toContainEqual({ category: 'reactivity', name: 'Suspense' }); + }); + it('does not claim React framework when react dependency is absent', async () => { const analyzer = new ReactAnalyzer(); const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `react-${randomUUID()}`); From 677bc98d7dccc5e90fcd5b20a83a1b9ffbbd3d22 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Sun, 5 Jul 2026 09:17:55 -0500 Subject: [PATCH 2/2] feat(analyzers): Recognize NestJS projects We work in NestJS code often enough that treating it as generic TypeScript loses useful context. Add a dedicated analyzer for controllers, modules, providers, routes, DI, and common Nest packages so backend code gets the same first-class treatment as React and Next.js. --- README.md | 2 +- docs/capabilities.md | 2 +- package.json | 5 + src/analyzers/nestjs/index.ts | 736 ++++++++++++++++++++++++++++++++ src/cli.ts | 2 + src/core/indexer.ts | 1 + src/index.ts | 2 + src/lib.ts | 7 + src/tools/search-codebase.ts | 2 +- src/types/index.ts | 3 +- tests/analyzer-registry.test.ts | 12 +- tests/indexer-metadata.test.ts | 28 ++ tests/nestjs-analyzer.test.ts | 214 ++++++++++ 13 files changed, 1010 insertions(+), 6 deletions(-) create mode 100644 src/analyzers/nestjs/index.ts create mode 100644 tests/nestjs-analyzer.test.ts diff --git a/README.md b/README.md index 92453b4..293de8e 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,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, React, and Next.js have dedicated analyzers; 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, Next.js, and NestJS have dedicated analyzers; everything else uses the Generic analyzer with AST-aligned chunking when a grammar is available. ## Configuration diff --git a/docs/capabilities.md b/docs/capabilities.md index 9085a07..7545148 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -305,6 +305,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, 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). +- **Angular, React, Next.js, and NestJS 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/package.json b/package.json index 00aeb98..01efad9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "import": "./dist/analyzers/nextjs/index.js", "types": "./dist/analyzers/nextjs/index.d.ts" }, + "./analyzers/nestjs": { + "import": "./dist/analyzers/nestjs/index.js", + "types": "./dist/analyzers/nestjs/index.d.ts" + }, "./analyzers/generic": { "import": "./dist/analyzers/generic/index.js", "types": "./dist/analyzers/generic/index.d.ts" @@ -87,6 +91,7 @@ "windsurf", "angular", "react", + "nestjs", "typescript", "developer-tools", "static-analysis", diff --git a/src/analyzers/nestjs/index.ts b/src/analyzers/nestjs/index.ts new file mode 100644 index 0000000..5912e34 --- /dev/null +++ b/src/analyzers/nestjs/index.ts @@ -0,0 +1,736 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse, type TSESTree } from '@typescript-eslint/typescript-estree'; +import type { + AnalysisResult, + ArchitecturalLayer, + 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 NestComponentType = + | 'module' + | 'controller' + | 'service' + | 'repository' + | 'provider' + | 'guard' + | 'interceptor' + | 'pipe' + | 'filter' + | 'gateway' + | 'resolver' + | 'processor' + | 'schema'; + +interface NestRouteInfo { + method: string; + path: string; + handler: string; + line?: number; +} + +interface NestModuleMetadata { + imports?: string[]; + providers?: string[]; + controllers?: string[]; + exports?: string[]; +} + +interface NestClassSummary { + component: CodeComponent; + routes: NestRouteInfo[]; +} + +const ROUTE_DECORATORS: Readonly> = { + Get: 'GET', + Post: 'POST', + Put: 'PUT', + Patch: 'PATCH', + Delete: 'DELETE', + Options: 'OPTIONS', + Head: 'HEAD', + All: 'ALL' +}; + +const NEST_LIBRARY_SIGNALS: ReadonlyArray<{ + source: string; + category: string; + name: string; +}> = [ + { source: '@nestjs/swagger', category: 'documentation', name: 'Swagger' }, + { source: '@nestjs/graphql', category: 'data', name: 'GraphQL' }, + { source: '@nestjs/mongoose', category: 'data', name: 'Mongoose' }, + { source: '@nestjs/bull', category: 'queues', name: 'Bull' }, + { source: '@nestjs/bullmq', category: 'queues', name: 'BullMQ' }, + { source: '@nestjs/schedule', category: 'scheduling', name: 'Schedule' }, + { source: '@nestjs/websockets', category: 'realtime', name: 'WebSockets' }, + { source: '@nestjs/passport', category: 'security', name: 'Passport' }, + { source: '@nestjs/terminus', category: 'health', name: 'Terminus' } +]; + +const TESTING_DEPENDENCIES: ReadonlyArray = [ + ['@nestjs/testing', 'Nest Testing'], + ['vitest', 'Vitest'], + ['jest', 'Jest'] +]; + +export class NestJsAnalyzer implements FrameworkAnalyzer { + readonly name = 'nestjs'; + readonly version = '1.0.0'; + readonly supportedExtensions = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + readonly priority = 85; + + canAnalyze(filePath: string, content?: string): boolean { + const extension = path.extname(filePath).toLowerCase(); + if (!this.supportedExtensions.includes(extension) || !content) { + return false; + } + + return /\bfrom\s+['"]@nestjs\//.test(content) || /\bNestFactory\b/.test(content); + } + + async analyze(filePath: string, content: string): Promise { + const extension = path.extname(filePath).toLowerCase(); + const language = + extension === '.ts' || 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, + sourceType: 'module', + experimentalDecorators: true + }); + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration' && typeof statement.source.value === 'string') { + const source = statement.source.value; + const packageName = getPackageName(source); + importSources.add(packageName); + 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(packageName); + } + } + + appendExports(exports, statement); + } + + const summaries = summarizeNestProgram(program); + components = summaries.map((summary) => summary.component); + addProgramPatterns(detectedPatterns, summaries, importSources); + } catch (error) { + console.warn(`Failed to parse NestJS file ${filePath}:`, error); + } + + const chunks = await createChunksFromCode( + content, + filePath, + relativePath, + language, + components, + { + framework: 'nestjs', + detectedPatterns + } + ); + + return { + filePath, + language, + framework: 'nestjs', + 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) + }) + ); + + const indicators = await detectNestIndicators(rootPath, packageInfo.allDependencies); + if (indicators.includes('dep:@nestjs/core') || indicators.includes('dep:@nestjs/common')) { + metadata.framework = { + name: 'NestJS', + version: normalizeAnalyzerVersion( + packageInfo.allDependencies['@nestjs/core'] || + packageInfo.allDependencies['@nestjs/common'] + ), + type: 'nestjs', + variant: getFrameworkVariant(packageInfo.allDependencies), + testingFrameworks: detectDependencyList( + packageInfo.allDependencies, + TESTING_DEPENDENCIES + ), + indicators + }; + } + + metadata.customMetadata = { + nestjs: { + packages: Object.keys(packageInfo.allDependencies) + .filter((name) => name.startsWith('@nestjs/')) + .sort() + } + }; + } catch (error) { + if (!isFileNotFoundError(error)) { + console.warn('Failed to read NestJS project metadata:', error); + } + } + + metadata.statistics = await loadAnalyzerIndexStatistics(rootPath); + return metadata; + } + + summarize(chunk: CodeChunk): string { + const nestMetadata = + typeof chunk.metadata.nestjs === 'object' && chunk.metadata.nestjs + ? (chunk.metadata.nestjs as { type?: unknown; routes?: unknown }) + : undefined; + + if (nestMetadata?.type === 'controller' && Array.isArray(nestMetadata.routes)) { + return `NestJS controller in ${path.basename(chunk.filePath)} with ${nestMetadata.routes.length} route handlers.`; + } + if (typeof nestMetadata?.type === 'string') { + return `NestJS ${nestMetadata.type} in ${path.basename(chunk.filePath)}: lines ${chunk.startLine}-${chunk.endLine}.`; + } + + return `NestJS code in ${path.basename(chunk.filePath)}: lines ${chunk.startLine}-${chunk.endLine}.`; + } +} + +function summarizeNestProgram(program: TSESTree.Program): NestClassSummary[] { + const summaries: NestClassSummary[] = []; + + walkAst(program, (node) => { + if (node.type !== 'ClassDeclaration' || !node.id?.name) { + return; + } + + const decorators = getDecoratorNames(node); + const type = getNestComponentType(node.id.name, decorators); + if (!type) { + return; + } + + const dependencies = extractConstructorDependencies(node); + const routes = type === 'controller' ? extractRoutes(node) : []; + const moduleMetadata = type === 'module' ? extractModuleMetadata(node) : undefined; + const component: CodeComponent = { + name: node.id.name, + type: 'class', + componentType: type, + startLine: node.loc?.start.line || 1, + endLine: node.loc?.end.line || node.loc?.start.line || 1, + layer: getComponentLayer(type), + decorators: decorators.map((name) => ({ name })), + dependencies, + metadata: { + nestjs: { + type, + decorators, + routes, + dependencies, + ...(moduleMetadata ? { moduleMetadata } : {}) + } + } + }; + + summaries.push({ component, routes }); + }); + + return dedupeSummaries(summaries); +} + +function addProgramPatterns( + patterns: DetectedPattern[], + summaries: NestClassSummary[], + importSources: Set +): void { + const patternKeys = new Set(); + const addPattern = (pattern: DetectedPattern): void => { + const key = `${pattern.category}:${pattern.name}`; + if (!patternKeys.has(key)) { + patterns.push(pattern); + patternKeys.add(key); + } + }; + + for (const summary of summaries) { + const type = summary.component.componentType; + if (type === 'module') addPattern({ category: 'modules', name: 'Nest modules' }); + if (type === 'controller') addPattern({ category: 'routing', name: 'Controllers' }); + if (type === 'gateway') addPattern({ category: 'realtime', name: 'WebSockets' }); + if (type === 'resolver') addPattern({ category: 'data', name: 'GraphQL' }); + if (summary.routes.length > 0) addPattern({ category: 'routing', name: 'REST routes' }); + if ((summary.component.dependencies || []).length > 0) { + addPattern({ category: 'dependencyInjection', name: 'Constructor injection' }); + } + const decorators = getNestMetadataDecorators(summary.component); + if (decorators.includes('UseGuards')) addPattern({ category: 'security', name: 'Guards' }); + if (decorators.some((decorator) => decorator.startsWith('Api'))) { + addPattern({ category: 'documentation', name: 'Swagger' }); + } + } + + for (const signal of NEST_LIBRARY_SIGNALS) { + if (importSources.has(signal.source)) { + addPattern({ category: signal.category, name: signal.name }); + } + } +} + +function getNestComponentType( + className: string, + decorators: readonly string[] +): NestComponentType | null { + if (decorators.includes('Module')) return 'module'; + if (decorators.includes('Controller')) return 'controller'; + if (decorators.includes('WebSocketGateway')) return 'gateway'; + if (decorators.includes('Resolver')) return 'resolver'; + if (decorators.includes('Processor')) return 'processor'; + if (decorators.includes('Schema')) return 'schema'; + if (decorators.includes('Catch')) return 'filter'; + if (!decorators.includes('Injectable')) return null; + + if (/Repository$/.test(className)) return 'repository'; + if (/Guard$/.test(className)) return 'guard'; + if (/Interceptor$/.test(className)) return 'interceptor'; + if (/Pipe$/.test(className)) return 'pipe'; + if (/Filter$/.test(className)) return 'filter'; + if (/Service$/.test(className)) return 'service'; + return 'provider'; +} + +function getComponentLayer(type: NestComponentType): ArchitecturalLayer { + if (type === 'controller' || type === 'resolver') return 'presentation'; + if (type === 'service') return 'business'; + if (type === 'repository' || type === 'schema') return 'data'; + if (type === 'module') return 'feature'; + if (type === 'gateway' || type === 'processor') return 'infrastructure'; + return 'core'; +} + +function extractRoutes(node: TSESTree.ClassDeclaration): NestRouteInfo[] { + const controllerPath = normalizeRouteSegment( + getDecoratorStringArgument(getDecorator(node, 'Controller')) || '' + ); + const routes: NestRouteInfo[] = []; + + for (const member of node.body.body) { + if (member.type !== 'MethodDefinition') continue; + const methodName = getPropertyName(member.key); + if (!methodName) continue; + + for (const decorator of getDecorators(member)) { + const decoratorName = getDecoratorName(decorator); + if (!decoratorName || !ROUTE_DECORATORS[decoratorName]) continue; + const routePath = normalizeRouteSegment(getDecoratorStringArgument(decorator) || ''); + routes.push({ + method: ROUTE_DECORATORS[decoratorName], + path: joinRoutePaths(controllerPath, routePath), + handler: methodName, + line: member.loc?.start.line + }); + } + } + + return routes; +} + +function extractConstructorDependencies(node: TSESTree.ClassDeclaration): string[] { + const dependencies = new Set(); + + for (const member of node.body.body) { + if (member.type !== 'MethodDefinition' || member.kind !== 'constructor') continue; + for (const parameter of member.value.params) { + const injectToken = getInjectToken(parameter); + if (injectToken) { + dependencies.add(injectToken); + continue; + } + + const typeName = getParameterTypeName(parameter); + if (typeName) dependencies.add(typeName); + } + } + + return Array.from(dependencies).sort(); +} + +function extractModuleMetadata(node: TSESTree.ClassDeclaration): NestModuleMetadata | undefined { + const moduleDecorator = getDecorator(node, 'Module'); + if (!moduleDecorator || moduleDecorator.expression.type !== 'CallExpression') { + return undefined; + } + + const firstArgument = moduleDecorator.expression.arguments[0]; + if (!firstArgument || firstArgument.type !== 'ObjectExpression') { + return undefined; + } + + const metadata: NestModuleMetadata = {}; + for (const property of firstArgument.properties) { + if (property.type !== 'Property') continue; + const key = getPropertyName(property.key); + if (!isModuleMetadataKey(key)) continue; + const values = extractExpressionNames(property.value); + if (values.length > 0) metadata[key] = values; + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function extractExpressionNames(expression: TSESTree.Node): string[] { + if (expression.type === 'ArrayExpression') { + return expression.elements.flatMap((element) => + element && element.type !== 'SpreadElement' ? extractExpressionNames(element) : [] + ); + } + if (expression.type === 'Identifier') return [expression.name]; + if (expression.type === 'Literal') return [String(expression.value)]; + if (expression.type === 'CallExpression') return [getCalleeName(expression.callee) || 'factory']; + if (expression.type === 'MemberExpression') { + const memberName = getMemberExpressionName(expression); + return memberName ? [memberName] : []; + } + return []; +} + +function getInjectToken(parameter: TSESTree.Parameter): string | null { + const decorators = getDecorators(parameter); + const injectDecorator = decorators.find((decorator) => getDecoratorName(decorator) === 'Inject'); + if (!injectDecorator || injectDecorator.expression.type !== 'CallExpression') { + return null; + } + + const token = injectDecorator.expression.arguments[0]; + if (!token || token.type === 'SpreadElement') return null; + const names = extractExpressionNames(token); + return names[0] || null; +} + +function getParameterTypeName(parameter: TSESTree.Parameter): string | null { + const target = parameter.type === 'TSParameterProperty' ? parameter.parameter : parameter; + if (target.type !== 'Identifier') { + return null; + } + + const annotation = target.typeAnnotation?.typeAnnotation; + if (!annotation) return null; + if (annotation.type === 'TSTypeReference') return getTypeName(annotation.typeName); + return null; +} + +function getTypeName(typeName: TSESTree.EntityName): string | null { + if (typeName.type === 'Identifier') return typeName.name; + if (typeName.type === 'TSQualifiedName') { + const left = getTypeName(typeName.left); + return left ? `${left}.${typeName.right.name}` : typeName.right.name; + } + return null; +} + +function getDecorator(node: TSESTree.Node, name: string): TSESTree.Decorator | undefined { + return getDecorators(node).find((decorator) => getDecoratorName(decorator) === name); +} + +function getDecoratorNames(node: TSESTree.Node): string[] { + return getDecorators(node) + .map(getDecoratorName) + .filter((name): name is string => Boolean(name)); +} + +function getDecorators(node: TSESTree.Node): TSESTree.Decorator[] { + const candidate = node as { decorators?: unknown }; + return Array.isArray(candidate.decorators) + ? candidate.decorators.filter((decorator): decorator is TSESTree.Decorator => + isDecoratorNode(decorator) + ) + : []; +} + +function isDecoratorNode(value: unknown): value is TSESTree.Decorator { + return Boolean( + value && typeof value === 'object' && (value as { type?: unknown }).type === 'Decorator' + ); +} + +function getDecoratorName(decorator: TSESTree.Decorator): string | null { + const expression = decorator.expression; + return expression.type === 'CallExpression' + ? getCalleeName(expression.callee) + : getCalleeName(expression); +} + +function getDecoratorStringArgument(decorator: TSESTree.Decorator | undefined): string | null { + if (!decorator || decorator.expression.type !== 'CallExpression') return null; + const firstArgument = decorator.expression.arguments[0]; + if (!firstArgument || firstArgument.type === 'SpreadElement') return null; + if (firstArgument.type === 'Literal' && typeof firstArgument.value === 'string') { + return firstArgument.value; + } + if (firstArgument.type === 'TemplateLiteral' && firstArgument.expressions.length === 0) { + return firstArgument.quasis[0]?.value.cooked || null; + } + return null; +} + +function getCalleeName(node: TSESTree.Node): string | null { + if (node.type === 'Identifier') return node.name; + if (node.type === 'MemberExpression') return getMemberExpressionName(node); + return null; +} + +function getMemberExpressionName(node: TSESTree.MemberExpression): string | null { + const objectName = + node.object.type === 'Identifier' + ? node.object.name + : node.object.type === 'MemberExpression' + ? getMemberExpressionName(node.object) + : null; + const propertyName = getPropertyName(node.property); + return objectName && propertyName ? `${objectName}.${propertyName}` : propertyName; +} + +function getPropertyName(node: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null { + if (node.type === 'Identifier') return node.name; + if (node.type === 'Literal') return String(node.value); + return null; +} + +function normalizeRouteSegment(segment: string): string { + return segment.replace(/^\/+|\/+$/g, '').replace(/:$/, ''); +} + +function joinRoutePaths(base: string, child: string): string { + const joined = [base, child] + .filter((segment) => segment.length > 0) + .join('/') + .replace(/\/+/g, '/'); + return `/${joined}`.replace(/\/$/, '') || '/'; +} + +function isModuleMetadataKey(key: string | null): key is keyof NestModuleMetadata { + return key === 'imports' || key === 'providers' || key === 'controllers' || key === 'exports'; +} + +function getNestMetadataDecorators(component: CodeComponent): string[] { + const metadata = component.metadata.nestjs as { decorators?: unknown } | undefined; + return Array.isArray(metadata?.decorators) + ? metadata.decorators.filter((decorator): decorator is string => typeof decorator === 'string') + : []; +} + +async function detectNestIndicators( + rootPath: string, + allDependencies: Record +): Promise { + const indicators: string[] = []; + for (const name of Object.keys(allDependencies).sort()) { + if (name.startsWith('@nestjs/')) indicators.push(`dep:${name}`); + } + + try { + await fs.stat(path.join(rootPath, 'nest-cli.json')); + indicators.push('disk:nest-cli-json'); + } catch { + // absent + } + if (await anyExists([path.join(rootPath, 'src', 'main.ts'), path.join(rootPath, 'main.ts')])) { + indicators.push('disk:main-ts'); + } + + return indicators; +} + +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(allDependencies: Record): string { + const hasPlatform = Boolean( + allDependencies['@nestjs/platform-express'] || allDependencies['@nestjs/platform-fastify'] + ); + const hasGraphql = Boolean(allDependencies['@nestjs/graphql']); + const hasMicroservices = Boolean(allDependencies['@nestjs/microservices']); + const hasRealtime = Boolean(allDependencies['@nestjs/websockets']); + + if (hasPlatform && (hasGraphql || hasMicroservices || hasRealtime)) return 'mixed'; + if (hasPlatform) return 'http-api'; + if (hasMicroservices) return 'microservice'; + return 'library'; +} + +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; +} + +function dedupeSummaries(summaries: NestClassSummary[]): NestClassSummary[] { + const seen = new Set(); + return summaries.filter((summary) => { + const key = `${summary.component.name}:${summary.component.startLine}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function walkAst( + node: TSESTree.Node, + visit: (node: TSESTree.Node, parent: TSESTree.Node | null) => void, + parent: TSESTree.Node | null = null +): void { + visit(node, parent); + for (const key of Object.keys(node) as Array) { + if (key === 'parent') continue; + const value = node[key]; + if (!value) continue; + if (Array.isArray(value)) { + for (const child of value) { + if (isAstNode(child)) walkAst(child, visit, node); + } + } else if (isAstNode(value)) { + walkAst(value, visit, node); + } + } +} + +function isAstNode(value: unknown): value is TSESTree.Node { + return Boolean( + value && typeof value === 'object' && typeof (value as { type?: unknown }).type === 'string' + ); +} diff --git a/src/cli.ts b/src/cli.ts index 1bc89f4..d2de83a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,7 @@ 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 { NestJsAnalyzer } from './analyzers/nestjs/index.js'; import { ReactAnalyzer } from './analyzers/react/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { formatJson } from './cli-formatters.js'; @@ -31,6 +32,7 @@ import { handleMapCli } from './cli-map.js'; analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new NextJsAnalyzer()); +analyzerRegistry.register(new NestJsAnalyzer()); analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 9530576..3d04bd9 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -282,6 +282,7 @@ export class CodebaseIndexer { analyzers: { angular: { enabled: true, priority: 100 }, nextjs: { enabled: false, priority: 90 }, + nestjs: { enabled: false, priority: 85 }, react: { enabled: false, priority: 90 }, vue: { enabled: false, priority: 90 }, generic: { enabled: true, priority: 10 } diff --git a/src/index.ts b/src/index.ts index 8d7386b..3f82078 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { CodebaseIndexer } from './core/indexer.js'; import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; import { NextJsAnalyzer } from './analyzers/nextjs/index.js'; +import { NestJsAnalyzer } from './analyzers/nestjs/index.js'; import { ReactAnalyzer } from './analyzers/react/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { IndexCorruptedError } from './errors/index.js'; @@ -62,6 +63,7 @@ import { analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new NextJsAnalyzer()); +analyzerRegistry.register(new NestJsAnalyzer()); analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); diff --git a/src/lib.ts b/src/lib.ts index 28f4d90..01ed318 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -12,6 +12,7 @@ * analyzerRegistry, * AngularAnalyzer, * NextJsAnalyzer, + * NestJsAnalyzer, * ReactAnalyzer, * GenericAnalyzer * } from 'codebase-context'; @@ -19,6 +20,7 @@ * // Register analyzers * analyzerRegistry.register(new AngularAnalyzer()); * analyzerRegistry.register(new NextJsAnalyzer()); + * analyzerRegistry.register(new NestJsAnalyzer()); * analyzerRegistry.register(new ReactAnalyzer()); * analyzerRegistry.register(new GenericAnalyzer()); * @@ -67,6 +69,8 @@ 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 { NestJsAnalyzer } from './analyzers/nestjs/index.js'; +import { NestJsAnalyzer } from './analyzers/nestjs/index.js'; export { ReactAnalyzer } from './analyzers/react/index.js'; import { ReactAnalyzer } from './analyzers/react/index.js'; export { GenericAnalyzer } from './analyzers/generic/index.js'; @@ -164,6 +168,9 @@ export function createIndexer( if (!analyzerRegistry.get('nextjs')) { analyzerRegistry.register(new NextJsAnalyzer()); } + if (!analyzerRegistry.get('nestjs')) { + analyzerRegistry.register(new NestJsAnalyzer()); + } if (!analyzerRegistry.get('react')) { analyzerRegistry.register(new ReactAnalyzer()); } diff --git a/src/tools/search-codebase.ts b/src/tools/search-codebase.ts index ab2ccc4..7a4f822 100644 --- a/src/tools/search-codebase.ts +++ b/src/tools/search-codebase.ts @@ -113,7 +113,7 @@ export const definition: Tool = { properties: { framework: { type: 'string', - description: 'Filter by framework (angular, react, nextjs, vue)' + description: 'Filter by framework (angular, react, nextjs, nestjs, vue)' }, language: { type: 'string', diff --git a/src/types/index.ts b/src/types/index.ts index a792517..54309c8 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' | 'nextjs' | 'vue' | 'svelte' | 'solid' | 'other'; + type: 'angular' | 'react' | 'nextjs' | 'nestjs' | 'vue' | 'svelte' | 'solid' | 'other'; variant?: string; // 'standalone', 'module-based', 'class-components', etc. stateManagement?: string[]; // 'ngrx', 'redux', 'zustand', 'pinia', etc. uiLibraries?: string[]; @@ -461,6 +461,7 @@ export interface CodebaseConfig { angular?: AnalyzerConfig; react?: AnalyzerConfig; nextjs?: AnalyzerConfig; + nestjs?: AnalyzerConfig; vue?: AnalyzerConfig; generic?: AnalyzerConfig; [key: string]: AnalyzerConfig | undefined; diff --git a/tests/analyzer-registry.test.ts b/tests/analyzer-registry.test.ts index 73d0712..ae7013e 100644 --- a/tests/analyzer-registry.test.ts +++ b/tests/analyzer-registry.test.ts @@ -2,12 +2,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 { NestJsAnalyzer } from '../src/analyzers/nestjs/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 NestJsAnalyzer()); analyzerRegistry.register(new ReactAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); @@ -47,12 +49,13 @@ describe('AnalyzerRegistry', () => { } }); - it('should include default analyzers (Angular, Next.js, React, Generic)', () => { + it('should include default analyzers (Angular, Next.js, NestJS, React, Generic)', () => { const analyzers = analyzerRegistry.getAll(); const names = analyzers.map((a) => a.name); expect(names).toContain('angular'); expect(names).toContain('nextjs'); + expect(names).toContain('nestjs'); expect(names).toContain('react'); expect(names).toContain('generic'); }); @@ -65,8 +68,9 @@ describe('AnalyzerRegistry', () => { expect(angular?.name).toBe('angular'); }); - it('should return nextjs and react analyzers by name', () => { + it('should return nextjs, nestjs, and react analyzers by name', () => { expect(analyzerRegistry.get('nextjs')?.name).toBe('nextjs'); + expect(analyzerRegistry.get('nestjs')?.name).toBe('nestjs'); expect(analyzerRegistry.get('react')?.name).toBe('react'); }); @@ -80,14 +84,18 @@ describe('AnalyzerRegistry', () => { it('should have Angular higher priority than Generic', () => { const angular = analyzerRegistry.get('angular'); const nextjs = analyzerRegistry.get('nextjs'); + const nestjs = analyzerRegistry.get('nestjs'); const react = analyzerRegistry.get('react'); const generic = analyzerRegistry.get('generic'); expect(angular).toBeDefined(); expect(nextjs).toBeDefined(); + expect(nestjs).toBeDefined(); expect(react).toBeDefined(); expect(generic).toBeDefined(); expect(angular!.priority).toBeGreaterThan(generic!.priority); + expect(nextjs!.priority).toBeGreaterThan(nestjs!.priority); + expect(nestjs!.priority).toBeGreaterThan(react!.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 8705447..b4c7ade 100644 --- a/tests/indexer-metadata.test.ts +++ b/tests/indexer-metadata.test.ts @@ -6,6 +6,7 @@ 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 { NestJsAnalyzer } from '../src/analyzers/nestjs/index'; import { ReactAnalyzer } from '../src/analyzers/react/index'; import { GenericAnalyzer } from '../src/analyzers/generic/index'; @@ -15,6 +16,9 @@ if (!analyzerRegistry.get('angular')) { if (!analyzerRegistry.get('nextjs')) { analyzerRegistry.register(new NextJsAnalyzer()); } +if (!analyzerRegistry.get('nestjs')) { + analyzerRegistry.register(new NestJsAnalyzer()); +} if (!analyzerRegistry.get('react')) { analyzerRegistry.register(new ReactAnalyzer()); } @@ -230,5 +234,29 @@ describe('CodebaseIndexer.detectMetadata', () => { expect(metadata.framework?.indicators).toContain('dep:@angular/core'); expect(metadata.framework?.indicators).toContain('disk:ng-package-json'); }); + + it('detects NestJS project metadata from @nestjs dependencies', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'api', + dependencies: { + '@nestjs/common': '11.1.19', + '@nestjs/core': '11.1.19', + '@nestjs/platform-express': '11.1.19', + }, + devDependencies: { + '@nestjs/testing': '11.1.19', + }, + }) + ); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework?.type).toBe('nestjs'); + expect(metadata.framework?.name).toBe('NestJS'); + expect(metadata.framework?.indicators).toContain('dep:@nestjs/core'); + }); }); }); diff --git a/tests/nestjs-analyzer.test.ts b/tests/nestjs-analyzer.test.ts new file mode 100644 index 0000000..bd08d8e --- /dev/null +++ b/tests/nestjs-analyzer.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { NestJsAnalyzer } from '../src/analyzers/nestjs/index'; + +const analyzer = new NestJsAnalyzer(); +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })) + ); +}); + +describe('NestJsAnalyzer', () => { + it('detects NestJS files without claiming generic decorator files', () => { + expect( + analyzer.canAnalyze( + '/tmp/app.controller.ts', + 'import { Controller } from "@nestjs/common";\n@Controller("health") export class HealthController {}' + ) + ).toBe(true); + expect( + analyzer.canAnalyze( + '/tmp/main.ts', + 'import { NestFactory } from "@nestjs/core";\nawait NestFactory.create(AppModule);' + ) + ).toBe(true); + expect( + analyzer.canAnalyze( + '/tmp/angular.service.ts', + 'import { Injectable } from "@angular/core";\n@Injectable() export class AngularService {}' + ) + ).toBe(false); + }); + + it('extracts controller routes, DI dependencies, guards, and Swagger patterns', async () => { + const content = ` +import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { ApiOAuth2, ApiTags } from "@nestjs/swagger"; +import { PermissionsGuard } from "./permissions.guard"; +import { PaymentsService } from "./payments.service"; + +@ApiTags("Payments") +@Controller("payments") +@UseGuards(AuthGuard("jwt"), PermissionsGuard) +export class PaymentsController { + constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {} + + @Get(":id") + findOne(@Param("id") id: string) { + return this.paymentsService.findOne(id); + } + + @Post() + create(@Body() dto: CreatePaymentDto) { + return this.paymentsService.create(dto); + } + + @Delete(":id") + remove(@Param("id") id: string) { + return this.paymentsService.remove(id); + } +} +`; + + const result = await analyzer.analyze('/tmp/payments.controller.ts', content); + const controller = result.components.find( + (component) => component.name === 'PaymentsController' + ); + + expect(result.framework).toBe('nestjs'); + expect(controller).toMatchObject({ + componentType: 'controller', + layer: 'presentation', + dependencies: ['PaymentsService'] + }); + expect(controller?.metadata.nestjs).toMatchObject({ + type: 'controller', + decorators: ['ApiTags', 'Controller', 'UseGuards'], + routes: [ + { method: 'GET', path: '/payments/:id', handler: 'findOne' }, + { method: 'POST', path: '/payments', handler: 'create' }, + { method: 'DELETE', path: '/payments/:id', handler: 'remove' } + ] + }); + expect(result.metadata.detectedPatterns).toContainEqual({ + category: 'routing', + name: 'REST routes' + }); + expect(result.metadata.detectedPatterns).toContainEqual({ + category: 'security', + name: 'Guards' + }); + expect(result.metadata.detectedPatterns).toContainEqual({ + category: 'documentation', + name: 'Swagger' + }); + }); + + it('extracts modules, services, gateways, and provider metadata', async () => { + const content = ` +import { Inject, Injectable, Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { WebSocketGateway } from "@nestjs/websockets"; + +@Injectable() +export class WidgetBootstrapService { + constructor(@Inject("API_CONFIG") private readonly config: ApiConfigService) {} +} + +@WebSocketGateway({ cors: true }) +export class RealtimeGateway {} + +@Module({ + imports: [ConfigModule], + providers: [WidgetBootstrapService, RealtimeGateway], + exports: [WidgetBootstrapService], +}) +export class WidgetBootstrapModule {} +`; + + const result = await analyzer.analyze('/tmp/widget-bootstrap.module.ts', content); + + expect(result.components.map((component) => component.componentType)).toEqual([ + 'service', + 'gateway', + 'module' + ]); + expect( + result.components.find((component) => component.name === 'WidgetBootstrapModule')?.metadata + .nestjs + ).toMatchObject({ + type: 'module', + moduleMetadata: { + imports: ['ConfigModule'], + providers: ['WidgetBootstrapService', 'RealtimeGateway'], + exports: ['WidgetBootstrapService'] + } + }); + expect(result.metadata.detectedPatterns).toContainEqual({ + category: 'realtime', + name: 'WebSockets' + }); + expect(result.metadata.detectedPatterns).toContainEqual({ + category: 'modules', + name: 'Nest modules' + }); + }); + + it('detects NestJS 11 metadata from package dependencies', async () => { + const root = await createTempProject({ + name: 'nestjs-api', + dependencies: { + '@nestjs/common': '11.1.19', + '@nestjs/core': '11.1.19', + '@nestjs/platform-express': '11.1.19', + '@nestjs/swagger': '11.4.2', + '@nestjs/graphql': '13.3.0', + '@nestjs/bullmq': '^11.0.4' + }, + devDependencies: { + '@nestjs/testing': '11.1.19', + vitest: '^3.0.0' + } + }); + + const metadata = await analyzer.detectCodebaseMetadata(root); + + expect(metadata.framework).toMatchObject({ + name: 'NestJS', + version: '11.1.19', + type: 'nestjs', + variant: 'mixed' + }); + expect(metadata.framework?.indicators).toEqual( + expect.arrayContaining([ + 'dep:@nestjs/common', + 'dep:@nestjs/core', + 'dep:@nestjs/platform-express' + ]) + ); + expect(metadata.framework?.testingFrameworks).toEqual(['Nest Testing', 'Vitest']); + expect(metadata.customMetadata.nestjs).toMatchObject({ + packages: expect.arrayContaining(['@nestjs/common', '@nestjs/core', '@nestjs/graphql']) + }); + }); + + it('detects older NestJS versions without version-specific assumptions', async () => { + const root = await createTempProject({ + name: 'legacy-nest-api', + dependencies: { + '@nestjs/common': '^8.4.0', + '@nestjs/core': '^8.4.0', + '@nestjs/platform-express': '^8.4.0' + } + }); + + const metadata = await analyzer.detectCodebaseMetadata(root); + + expect(metadata.framework?.type).toBe('nestjs'); + expect(metadata.framework?.version).toBe('8.4.0'); + }); +}); + +async function createTempProject(packageJson: Record): Promise { + const root = path.join(process.cwd(), 'tests', '.tmp', `nestjs-${randomUUID()}`); + tempRoots.push(root); + await fs.mkdir(root, { recursive: true }); + await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(packageJson)); + return root; +}