From 2cf709b9d36c92db131065d5e9f3547f9ec077d2 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Sun, 5 Jul 2026 02:33:25 -0500 Subject: [PATCH 1/5] 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 d083c3a272267bf6fc00c1cebb5f7121866abd14 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Sun, 5 Jul 2026 09:17:55 -0500 Subject: [PATCH 2/5] 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 3ec44d1..a1fb5a0 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; +} From 886e1cf0a65a33fe2248c9aec3751290ba3d1058 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Wed, 15 Jul 2026 15:52:54 -0500 Subject: [PATCH 3/5] feat(indexing): Make chunk limits configurable per project Large repositories were silently limited to the first 5,000 searchable chunks. Let each configured project raise that safety limit while keeping 5,000 as the default for smaller codebases. Use the same setting in MCP server and CLI reindex paths so both keyword and semantic indexes cover the same files. --- README.md | 13 +++++++ src/cli.ts | 3 ++ src/core/indexer.ts | 12 ++++--- src/index.ts | 13 +++++-- src/project-state.ts | 2 ++ src/server/config.ts | 23 ++++++++++++ src/types/index.ts | 1 + tests/indexer-large-file-skip.test.ts | 25 +++++++++++++ tests/server-config.test.ts | 52 ++++++++++++++++++++++++++- 9 files changed, 136 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 293de8e..ed333e2 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,19 @@ If you get `selection_required`, retry with one of the paths from `availableProj | `CODEBASE_CONTEXT_PORT` | `3100` | HTTP server port override (same as `--port`; ignored in stdio mode) | | `CODEBASE_CONTEXT_CONFIG_PATH` | `~/.codebase-context/config.json` | Override the server config file path | +Large projects can override the default 5,000 searchable-chunk safety limit per project: + +```json +{ + "projects": [ + { + "root": "/path/to/large-project", + "parsing": { "maxChunks": 25000 } + } + ] +} +``` + ## Performance - **First indexing**: 2-5 minutes for ~30k files (embedding computation). diff --git a/src/cli.ts b/src/cli.ts index d2de83a..c7a0ed4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { handleMemoryCli } from './cli-memory.js'; export { handleMemoryCli } from './cli-memory.js'; import { handleInitCli } from './cli-init.js'; import { handleMapCli } from './cli-map.js'; +import { loadProjectConfig } from './server/config.js'; analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new NextJsAnalyzer()); @@ -105,6 +106,7 @@ function resolveCliRootPath(): string { async function initToolContext(): Promise { const rootPath = resolveCliRootPath(); + const projectConfig = await loadProjectConfig(rootPath); const paths = { baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME), @@ -136,6 +138,7 @@ async function initToolContext(): Promise { let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ rootPath, + ...(projectConfig?.parsing ? { config: { parsing: projectConfig.parsing } } : {}), incrementalOnly, onProgress: (progress) => { const shouldLog = diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 3d04bd9..b4e68d2 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -57,6 +57,7 @@ import { deriveCodebaseHealth } from '../health/derive.js'; const STAGING_DIRNAME = '.staging'; const PREVIOUS_DIRNAME = '.previous'; +const DEFAULT_MAX_CHUNKS = 5000; import { computeFileHashes, @@ -302,6 +303,7 @@ export class CodebaseIndexer { respectGitignore: true, parsing: { maxFileSize: 1048576, + maxChunks: DEFAULT_MAX_CHUNKS, chunkSize: 50, chunkOverlap: 0, parseTests: true, @@ -749,13 +751,13 @@ export class CodebaseIndexer { const chunksForEmbedding = diff ? changedChunks : allChunks; // Memory safety: limit chunks to prevent embedding memory issues - const MAX_CHUNKS = 5000; + const maxChunks = this.config.parsing.maxChunks ?? DEFAULT_MAX_CHUNKS; let chunksToEmbed = chunksForEmbedding; - if (chunksForEmbedding.length > MAX_CHUNKS) { + if (chunksForEmbedding.length > maxChunks) { console.warn( - `WARNING: ${chunksForEmbedding.length} chunks exceed limit. Indexing first ${MAX_CHUNKS} chunks.` + `WARNING: ${chunksForEmbedding.length} chunks exceed limit. Indexing first ${maxChunks} chunks.` ); - chunksToEmbed = chunksForEmbedding.slice(0, MAX_CHUNKS); + chunksToEmbed = chunksForEmbedding.slice(0, maxChunks); } // Phase 3: Embedding (only changed/added chunks in incremental mode) @@ -895,7 +897,7 @@ export class CodebaseIndexer { const indexPath = path.join(activeContextDir, KEYWORD_INDEX_FILENAME); // Memory safety: cap keyword index too const keywordChunks = - allChunks.length > MAX_CHUNKS ? allChunks.slice(0, MAX_CHUNKS) : allChunks; + allChunks.length > maxChunks ? allChunks.slice(0, maxChunks) : allChunks; await fs.writeFile( indexPath, JSON.stringify({ diff --git a/src/index.ts b/src/index.ts index 3f82078..5f3e551 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1251,10 +1251,15 @@ async function performIndexingOnce( ...(project.runtimeOverrides.extraExcludePatterns?.length ? { config: { - exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.runtimeOverrides.extraExcludePatterns] + exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.runtimeOverrides.extraExcludePatterns], + ...(project.runtimeOverrides.maxChunks + ? { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } + : {}) } } - : {}), + : project.runtimeOverrides.maxChunks + ? { config: { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } } + : {}), ...(project.runtimeOverrides.preferredAnalyzer || project.runtimeOverrides.extraSourceExtensions?.length ? { @@ -1638,6 +1643,10 @@ function buildProjectRuntimeOverrides(projectConfig: ProjectConfig): ProjectRunt runtimeOverrides.extraExcludePatterns = [...projectConfig.excludePatterns]; } + if (projectConfig.parsing?.maxChunks) { + runtimeOverrides.maxChunks = projectConfig.parsing.maxChunks; + } + if (projectConfig.analyzerHints?.analyzer) { runtimeOverrides.preferredAnalyzer = projectConfig.analyzerHints.analyzer.trim(); } diff --git a/src/project-state.ts b/src/project-state.ts index 4155017..deaa5a6 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -14,6 +14,8 @@ import type { ToolPaths, IndexState } from './tools/types.js'; export interface ProjectRuntimeOverrides { /** Extra glob exclusion patterns merged with the default index-time exclusions. */ extraExcludePatterns?: string[]; + /** Maximum chunks retained in semantic and keyword indexes for this project. */ + maxChunks?: number; /** Analyzer name to prefer for this project without mutating global registry order. */ preferredAnalyzer?: string; /** Additional source extensions treated as code for this project only. */ diff --git a/src/server/config.ts b/src/server/config.ts index b74ed6d..ff6c1d6 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -5,6 +5,9 @@ import path from 'node:path'; export interface ProjectConfig { root: string; excludePatterns?: string[]; + parsing?: { + maxChunks?: number; + }; analyzerHints?: { extensions?: string[]; analyzer?: string; @@ -87,6 +90,20 @@ export async function loadServerConfig(): Promise { parsedProject.excludePatterns = excludePatterns; } + if ( + typeof project.parsing === 'object' && + project.parsing !== null && + !Array.isArray(project.parsing) + ) { + const parsing = project.parsing as Record; + const maxChunks = parsing.maxChunks; + if (typeof maxChunks === 'number' && Number.isInteger(maxChunks) && maxChunks > 0) { + parsedProject.parsing = { maxChunks }; + } else if (maxChunks !== undefined) { + console.error(`[config] Ignoring invalid project parsing.maxChunks: ${maxChunks}`); + } + } + if ( typeof project.analyzerHints === 'object' && project.analyzerHints !== null && @@ -137,3 +154,9 @@ export async function loadServerConfig(): Promise { return result; } + +export async function loadProjectConfig(rootPath: string): Promise { + const serverConfig = await loadServerConfig(); + const resolvedRoot = path.resolve(rootPath); + return serverConfig?.projects?.find((project) => project.root === resolvedRoot); +} diff --git a/src/types/index.ts b/src/types/index.ts index 54309c8..9010843 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -475,6 +475,7 @@ export interface CodebaseConfig { // Parsing options parsing: { maxFileSize?: number; // bytes + maxChunks?: number; chunkSize?: number; // lines chunkOverlap?: number; // lines parseTests?: boolean; diff --git a/tests/indexer-large-file-skip.test.ts b/tests/indexer-large-file-skip.test.ts index 741fb68..e54e513 100644 --- a/tests/indexer-large-file-skip.test.ts +++ b/tests/indexer-large-file-skip.test.ts @@ -57,4 +57,29 @@ describe('Indexer large file skip regression', () => { expect(indexedFiles.has('big.ts')).toBe(false); expect(indexedFiles.has('big.generated.ts')).toBe(false); }); + + it('limits the keyword index to the configured maximum chunk count', async () => { + await Promise.all( + ['one.ts', 'two.ts', 'three.ts'].map((fileName, index) => + fs.writeFile(path.join(tempDir, fileName), `export const value${index} = ${index};\n`) + ) + ); + + const indexer = new CodebaseIndexer({ + rootPath: tempDir, + config: { + skipEmbedding: true, + parsing: { maxChunks: 2 } + } + }); + + await indexer.index(); + + const indexPath = path.join(tempDir, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME); + const indexRaw = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as { + chunks?: unknown[]; + }; + + expect(indexRaw.chunks).toHaveLength(2); + }); }); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index de44feb..eb7f6c6 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { loadServerConfig } from '../src/server/config.js'; +import { loadProjectConfig, loadServerConfig } from '../src/server/config.js'; // Helper: write a temp config file and set CODEBASE_CONTEXT_CONFIG_PATH async function withTempConfig(content: string, fn: (filePath: string) => Promise) { @@ -158,6 +158,56 @@ describe('loadServerConfig', () => { }); }); + it('parses a positive per-project maxChunks value', async () => { + const config = JSON.stringify({ + projects: [{ root: '~/large-repo', parsing: { maxChunks: 25000 } }] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + + expect(result?.projects?.[0].parsing).toEqual({ maxChunks: 25000 }); + }); + }); + + it('loads parsing configuration for the requested CLI project root', async () => { + const projectRoot = path.join(os.tmpdir(), 'ccc-large-cli-project'); + const config = JSON.stringify({ + projects: [ + { root: path.join(os.tmpdir(), 'ccc-other-project') }, + { root: projectRoot, parsing: { maxChunks: 25000 } } + ] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const projectConfig = await loadProjectConfig(projectRoot); + + expect(projectConfig).toEqual({ + root: projectRoot, + parsing: { maxChunks: 25000 } + }); + }); + }); + + it('ignores an invalid per-project maxChunks value', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ + projects: [{ root: '~/large-repo', parsing: { maxChunks: 0 } }] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + + expect(result?.projects?.[0].parsing).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + '[config] Ignoring invalid project parsing.maxChunks: 0' + ); + }); + }); + it('drops empty analyzerHints objects after parsing', async () => { const config = JSON.stringify({ projects: [ From 2b2ba120812b36cd65d6fd612d246068d5686386 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Wed, 15 Jul 2026 16:01:32 -0500 Subject: [PATCH 4/5] chore(memory): Record recent repository fixes Keep the auto-extracted Git history in project memory so future searches can surface the fixes and test constraints already handled in this repository. --- .codebase-context/memory.json | 180 ++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/.codebase-context/memory.json b/.codebase-context/memory.json index f0eee24..868ef15 100644 --- a/.codebase-context/memory.json +++ b/.codebase-context/memory.json @@ -569,5 +569,185 @@ "scope": { "kind": "global" } + }, + { + "id": "cbe8bd1377ba", + "type": "gotcha", + "category": "conventions", + "memory": "fix(analyzers): Recognize current React and Next patterns", + "reason": "Auto-extracted from git commit history", + "date": "2026-07-05T07:33:25.000Z", + "source": "git" + }, + { + "id": "e3b009c312e8", + "type": "gotcha", + "category": "conventions", + "memory": "fix(reranker): Score passages with the supported tokenizer pair API", + "reason": "Auto-extracted from git commit history", + "date": "2026-07-05T14:43:29.000Z", + "source": "git" + }, + { + "id": "449b60830c9b", + "type": "gotcha", + "category": "conventions", + "memory": "fix(eval): deduplicate blocked ContextBench rows", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-30T15:45:12.000Z", + "source": "git" + }, + { + "id": "9dfaf21a0bc8", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): harden ContextBench schema cleanup", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T19:14:49.000Z", + "source": "git" + }, + { + "id": "b8776a500ef5", + "type": "gotcha", + "category": "conventions", + "memory": "fix(eval): preserve ContextBench executor model provenance", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T19:04:04.000Z", + "source": "git" + }, + { + "id": "a8b0f8664253", + "type": "gotcha", + "category": "conventions", + "memory": "fix(eval): harden ContextBench manifest checks", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T19:03:51.000Z", + "source": "git" + }, + { + "id": "f84080651a19", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): relax zombie guard timeout jitter", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T18:24:56.000Z", + "source": "git" + }, + { + "id": "3d7776af8a23", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): tolerate ContextBench runner cleanup races", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T18:21:18.000Z", + "source": "git" + }, + { + "id": "6428cb02706e", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): tolerate ContextBench schema cleanup races", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T18:13:07.000Z", + "source": "git" + }, + { + "id": "c1bd14e88de2", + "type": "gotcha", + "category": "conventions", + "memory": "fix(eval): align ContextBench harness evidence contracts", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T18:08:08.000Z", + "source": "git" + }, + { + "id": "2f3db0023789", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): relax slow Windows integration timeouts", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T17:59:20.000Z", + "source": "git" + }, + { + "id": "da039b1d0ff8", + "type": "gotcha", + "category": "conventions", + "memory": "fix(eval): harden ContextBench fixture verification", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T17:55:12.000Z", + "source": "git" + }, + { + "id": "5e3bdcaa984c", + "type": "gotcha", + "category": "conventions", + "memory": "fix(git): scope local artifact ignores", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T17:50:47.000Z", + "source": "git" + }, + { + "id": "861eb0bfb69f", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): relax slow Windows search timeouts", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T16:47:38.000Z", + "source": "git" + }, + { + "id": "3614711e00a4", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): tolerate ContextBench temp cleanup races", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T16:29:24.000Z", + "source": "git" + }, + { + "id": "c0cfb6599bcc", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): isolate ContextBench baseline Git env", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T16:25:08.000Z", + "source": "git" + }, + { + "id": "084a0540558c", + "type": "gotcha", + "category": "conventions", + "memory": "fix(format): format ContextBench harness sources", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T16:06:53.000Z", + "source": "git" + }, + { + "id": "0153743292c4", + "type": "gotcha", + "category": "conventions", + "memory": "fix(test): isolate ContextBench git fixtures", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-29T15:56:57.000Z", + "source": "git" + }, + { + "id": "a5397b2ee20d", + "type": "gotcha", + "category": "conventions", + "memory": "fix(cli): lazy-load mcp runtime for direct commands", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-17T14:34:22.000Z", + "source": "git" + }, + { + "id": "377e7c7c37b1", + "type": "gotcha", + "category": "conventions", + "memory": "fix(ci): unblock functional tests", + "reason": "Auto-extracted from git commit history", + "date": "2026-04-17T20:05:19.000Z", + "source": "git" } ] \ No newline at end of file From 4d52615027012deb04f467ff7c4c296551a07316 Mon Sep 17 00:00:00 2001 From: Aaron Olin Date: Wed, 15 Jul 2026 16:11:17 -0500 Subject: [PATCH 5/5] style(indexing): Format project index overrides --- src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 5f3e551..ab539b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1251,7 +1251,10 @@ async function performIndexingOnce( ...(project.runtimeOverrides.extraExcludePatterns?.length ? { config: { - exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.runtimeOverrides.extraExcludePatterns], + exclude: [ + ...EXCLUDED_GLOB_PATTERNS, + ...project.runtimeOverrides.extraExcludePatterns + ], ...(project.runtimeOverrides.maxChunks ? { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } : {})