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/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/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; +} 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()}`);