From 0e32b2627624dc22d36eca18094c543d421a8668 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 11 Apr 2026 23:13:03 +0200 Subject: [PATCH 1/2] fix(metadata): prevent framework misclassification in codebase detection Fixes false-positive framework claims on generic Node repos by: - Adding framework.indicators to FrameworkInfo (evidence signals per claim) - Adding dep guards to Next.js and React analyzers (framework only if core dep present) - Adding indicator collection to Angular analyzer (inside existing guard) - Enforcing >=3 indicator threshold in indexer.mergeMetadata() before promoting claims These changes prevent repos without react/nextjs/@angular/core dependencies from being incorrectly flagged with a framework type. Framework claims now require enumerated evidence signals that demonstrate genuine framework presence. Tests: 21/21 passing (4 new negative tests, 2 indicator assertions, 4 merge E2E) --- src/analyzers/angular/index.ts | 21 +++++++++- src/analyzers/nextjs/index.ts | 74 +++++++++++++++++++++------------- src/analyzers/react/index.ts | 66 +++++++++++++++++------------- src/core/indexer.ts | 23 ++++++++++- src/types/index.ts | 2 + tests/indexer-metadata.test.ts | 61 ++++++++++++++++++++++++++++ tests/nextjs-analyzer.test.ts | 40 ++++++++++++++++++ tests/react-analyzer.test.ts | 41 +++++++++++++++++++ 8 files changed, 272 insertions(+), 56 deletions(-) diff --git a/src/analyzers/angular/index.ts b/src/analyzers/angular/index.ts index dcede76..4f61b40 100644 --- a/src/analyzers/angular/index.ts +++ b/src/analyzers/angular/index.ts @@ -933,6 +933,24 @@ export class AngularAnalyzer implements FrameworkAnalyzer { if (allDeps['jest']) testingFrameworks.push('Jest'); if (angularCoreVersion) { + // Collect evidence indicators for the merge threshold check. + const indicators: string[] = ['dep:@angular/core']; + if (allDeps['@angular/common']) indicators.push('dep:@angular/common'); + if (allDeps['@angular/compiler-cli']) indicators.push('dep:@angular/compiler-cli'); + if (allDeps['@angular/cli']) indicators.push('dep:@angular/cli'); + try { + await fs.stat(path.join(rootPath, 'angular.json')); + indicators.push('disk:angular-json'); + } catch { + /* absent */ + } + try { + await fs.stat(path.join(rootPath, 'tsconfig.app.json')); + indicators.push('disk:tsconfig-app'); + } catch { + /* absent */ + } + metadata.framework = { name: 'Angular', version: angularCoreVersion.replace(/[\^~]/, ''), @@ -940,7 +958,8 @@ export class AngularAnalyzer implements FrameworkAnalyzer { variant: 'unknown', // Will be determined during analysis stateManagement, uiLibraries, - testingFrameworks + testingFrameworks, + indicators }; } diff --git a/src/analyzers/nextjs/index.ts b/src/analyzers/nextjs/index.ts index ca68383..79fa6c5 100644 --- a/src/analyzers/nextjs/index.ts +++ b/src/analyzers/nextjs/index.ts @@ -198,33 +198,53 @@ export class NextJsAnalyzer implements FrameworkAnalyzer { category: categorizeDependency(name) }) ); - metadata.framework = { - name: 'Next.js', - version: normalizeAnalyzerVersion(packageInfo.allDependencies.next), - type: 'nextjs', - variant: getFrameworkVariant(routerPresence), - stateManagement: detectDependencyList(packageInfo.allDependencies, [ - ['@reduxjs/toolkit', 'redux'], - ['redux', 'redux'], - ['zustand', 'zustand'], - ['jotai', 'jotai'], - ['recoil', 'recoil'], - ['mobx', 'mobx'] - ]), - uiLibraries: detectDependencyList(packageInfo.allDependencies, [ - ['tailwindcss', 'Tailwind'], - ['@mui/material', 'MUI'], - ['styled-components', 'styled-components'], - ['@radix-ui/react-slot', 'Radix UI'] - ]), - testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ - ['vitest', 'Vitest'], - ['jest', 'Jest'], - ['@testing-library/react', 'Testing Library'], - ['playwright', 'Playwright'], - ['cypress', 'Cypress'] - ]) - }; + + // Collect evidence indicators before claiming framework. + const indicators: string[] = []; + if (packageInfo.allDependencies.next) indicators.push('dep:next'); + if (packageInfo.allDependencies.react) indicators.push('dep:react'); + if (packageInfo.allDependencies['react-dom']) indicators.push('dep:react-dom'); + if (routerPresence.hasAppRouter) indicators.push('disk:app-router'); + if (routerPresence.hasPagesRouter) indicators.push('disk:pages-router'); + const nextConfigCandidates = [ + path.join(rootPath, 'next.config.js'), + path.join(rootPath, 'next.config.ts'), + path.join(rootPath, 'next.config.mjs'), + path.join(rootPath, 'next.config.cjs') + ]; + if (await anyExists(nextConfigCandidates)) indicators.push('disk:next-config'); + + // Only claim Next.js when the next package is an actual dependency. + if (indicators.includes('dep:next')) { + metadata.framework = { + name: 'Next.js', + version: normalizeAnalyzerVersion(packageInfo.allDependencies.next), + type: 'nextjs', + variant: getFrameworkVariant(routerPresence), + stateManagement: detectDependencyList(packageInfo.allDependencies, [ + ['@reduxjs/toolkit', 'redux'], + ['redux', 'redux'], + ['zustand', 'zustand'], + ['jotai', 'jotai'], + ['recoil', 'recoil'], + ['mobx', 'mobx'] + ]), + uiLibraries: detectDependencyList(packageInfo.allDependencies, [ + ['tailwindcss', 'Tailwind'], + ['@mui/material', 'MUI'], + ['styled-components', 'styled-components'], + ['@radix-ui/react-slot', 'Radix UI'] + ]), + testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ + ['vitest', 'Vitest'], + ['jest', 'Jest'], + ['@testing-library/react', 'Testing Library'], + ['playwright', 'Playwright'], + ['cypress', 'Cypress'] + ]), + indicators + }; + } metadata.customMetadata = { nextjs: routerPresence }; diff --git a/src/analyzers/react/index.ts b/src/analyzers/react/index.ts index 8b63196..bc6bbdd 100644 --- a/src/analyzers/react/index.ts +++ b/src/analyzers/react/index.ts @@ -225,33 +225,45 @@ export class ReactAnalyzer implements FrameworkAnalyzer { category: categorizeDependency(name) }) ); - metadata.framework = { - name: 'React', - version: normalizeAnalyzerVersion(packageInfo.allDependencies.react), - type: 'react', - variant: 'unknown', - stateManagement: detectDependencyList(packageInfo.allDependencies, [ - ['@reduxjs/toolkit', 'redux'], - ['redux', 'redux'], - ['zustand', 'zustand'], - ['jotai', 'jotai'], - ['recoil', 'recoil'], - ['mobx', 'mobx'] - ]), - uiLibraries: detectDependencyList(packageInfo.allDependencies, [ - ['tailwindcss', 'Tailwind'], - ['@mui/material', 'MUI'], - ['styled-components', 'styled-components'], - ['@radix-ui/react-slot', 'Radix UI'] - ]), - testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ - ['vitest', 'Vitest'], - ['jest', 'Jest'], - ['@testing-library/react', 'Testing Library'], - ['playwright', 'Playwright'], - ['cypress', 'Cypress'] - ]) - }; + + // Collect evidence indicators before claiming framework. + const indicators: string[] = []; + if (packageInfo.allDependencies.react) indicators.push('dep:react'); + if (packageInfo.allDependencies['react-dom']) indicators.push('dep:react-dom'); + if (packageInfo.allDependencies['@types/react']) indicators.push('dep:@types/react'); + if (packageInfo.allDependencies['react-native']) indicators.push('dep:react-native'); + + // Only claim React when the react package is an actual dependency. + if (indicators.includes('dep:react')) { + metadata.framework = { + name: 'React', + version: normalizeAnalyzerVersion(packageInfo.allDependencies.react), + type: 'react', + variant: 'unknown', + stateManagement: detectDependencyList(packageInfo.allDependencies, [ + ['@reduxjs/toolkit', 'redux'], + ['redux', 'redux'], + ['zustand', 'zustand'], + ['jotai', 'jotai'], + ['recoil', 'recoil'], + ['mobx', 'mobx'] + ]), + uiLibraries: detectDependencyList(packageInfo.allDependencies, [ + ['tailwindcss', 'Tailwind'], + ['@mui/material', 'MUI'], + ['styled-components', 'styled-components'], + ['@radix-ui/react-slot', 'Radix UI'] + ]), + testingFrameworks: detectDependencyList(packageInfo.allDependencies, [ + ['vitest', 'Vitest'], + ['jest', 'Jest'], + ['@testing-library/react', 'Testing Library'], + ['playwright', 'Playwright'], + ['cypress', 'Cypress'] + ]), + indicators + }; + } } catch (error) { if (!isFileNotFoundError(error)) { console.warn('Failed to read React project metadata:', error); diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 20e7c68..5aa0747 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -11,6 +11,7 @@ import ignore from 'ignore'; import { CodebaseMetadata, CodeChunk, + FrameworkInfo, IndexingProgress, IndexingStats, IndexingPhase, @@ -1243,7 +1244,7 @@ export class CodebaseIndexer { rootPath: incoming.rootPath || base.rootPath, languages: [...new Set([...base.languages, ...incoming.languages])], // Merge and deduplicate dependencies: this.mergeDependencies(base.dependencies, incoming.dependencies), - framework: base.framework || incoming.framework, // Framework from higher priority analyzer wins + framework: this.selectFramework(base.framework, incoming.framework), architecture: { type: incoming.architecture?.type || base.architecture.type, layers: this.mergeLayers(base.architecture.layers, incoming.architecture?.layers), @@ -1265,6 +1266,26 @@ export class CodebaseIndexer { }; } + /** + * Select the best framework claim from two candidates. + * Requires at least MIN_INDICATORS evidence signals — prevents analyzers from claiming + * a framework when only one weak signal is present (e.g. a single import). + * Priority-order callers (highest priority first) ensure the first passing candidate wins. + */ + private selectFramework( + base: FrameworkInfo | undefined, + incoming: FrameworkInfo | undefined + ): FrameworkInfo | undefined { + const MIN_INDICATORS = 3; + const passes = (f: FrameworkInfo | undefined): f is FrameworkInfo => + !!f && (f.indicators?.length ?? 0) >= MIN_INDICATORS; + + if (passes(base) && passes(incoming)) return base; // higher-priority analyzer wins + if (passes(base)) return base; + if (passes(incoming)) return incoming; + return undefined; + } + private mergeDependencies(base: Dependency[], incoming: Dependency[]): Dependency[] { const seen = new Set(base.map((d) => d.name)); const result = [...base]; diff --git a/src/types/index.ts b/src/types/index.ts index 2ae6ca2..7ca5865 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -211,6 +211,8 @@ export interface FrameworkInfo { stateManagement?: string[]; // 'ngrx', 'redux', 'zustand', 'pinia', etc. uiLibraries?: string[]; testingFrameworks?: string[]; + /** Enumerated evidence signals that triggered this framework claim (e.g. 'dep:next', 'disk:app-router'). */ + indicators?: readonly string[]; } export interface LanguageInfo { diff --git a/tests/indexer-metadata.test.ts b/tests/indexer-metadata.test.ts index 8b6e35c..2380102 100644 --- a/tests/indexer-metadata.test.ts +++ b/tests/indexer-metadata.test.ts @@ -143,4 +143,65 @@ describe('CodebaseIndexer.detectMetadata', () => { expect(typeof metadata.customMetadata).toBe('object'); }); }); + + describe('framework misclassification guards', () => { + it('does not claim framework for plain Node project', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'plain-node', dependencies: { zod: '^3' } }) + ); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework).toBeUndefined(); + }); + + it('drops React claim when indicators are below threshold', async () => { + // react alone is only 1 indicator — should not meet the >=3 threshold + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'thin-react', dependencies: { react: '^18' } }) + ); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework).toBeUndefined(); + }); + + it('preserves Next.js preference over React when both pass threshold', async () => { + // next + react + react-dom + app/ = 4 Next.js indicators; react + react-dom = 2 React indicators + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'next-project', + dependencies: { next: '^14.1.0', react: '^18.2.0', 'react-dom': '^18.2.0' }, + }) + ); + await fs.mkdir(path.join(tempDir, 'app'), { recursive: true }); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework?.type).toBe('nextjs'); + }); + + it('detects React when sufficient indicators are present', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'react-app', + dependencies: { react: '^18', 'react-dom': '^18' }, + devDependencies: { '@types/react': '^18' }, + }) + ); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework?.type).toBe('react'); + expect(metadata.framework?.indicators).toContain('dep:react'); + }); + }); }); diff --git a/tests/nextjs-analyzer.test.ts b/tests/nextjs-analyzer.test.ts index 9bcdfc7..1882dbb 100644 --- a/tests/nextjs-analyzer.test.ts +++ b/tests/nextjs-analyzer.test.ts @@ -136,4 +136,44 @@ export default function Page() { return
; } await rm(tempRoot, { recursive: true, force: true }); } }); + + it('does not claim Next.js framework when next dependency is absent', async () => { + const analyzer = new NextJsAnalyzer(); + const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `nextjs-${randomUUID()}`); + await mkdir(tempRoot, { recursive: true }); + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ name: 'react-only', dependencies: { react: '^18', 'react-dom': '^18' } }) + ); + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework).toBeUndefined(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('populates framework.indicators when Next.js is detected', async () => { + const analyzer = new NextJsAnalyzer(); + const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `nextjs-${randomUUID()}`); + await mkdir(path.join(tempRoot, 'app'), { recursive: true }); + await mkdir(path.join(tempRoot, 'pages'), { recursive: true }); + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ + name: 'next-app', + dependencies: { next: '^14', react: '^18', 'react-dom': '^18' } + }) + ); + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework?.type).toBe('nextjs'); + expect(metadata.framework?.indicators).toContain('dep:next'); + expect(metadata.framework?.indicators).toContain('dep:react'); + expect(metadata.framework?.indicators).toContain('disk:app-router'); + expect(metadata.framework?.indicators).toContain('disk:pages-router'); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/tests/react-analyzer.test.ts b/tests/react-analyzer.test.ts index 1d9e9a8..e1e695b 100644 --- a/tests/react-analyzer.test.ts +++ b/tests/react-analyzer.test.ts @@ -1,3 +1,5 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; import path from 'path'; import { describe, expect, it } from 'vitest'; import { ReactAnalyzer } from '../src/analyzers/react/index'; @@ -77,4 +79,43 @@ export class LegacyWidget extends Component { expect(patterns).toContainEqual({ category: 'stateManagement', name: 'redux-toolkit' }); expect(patterns).toContainEqual({ category: 'styling', name: 'tailwind' }); }); + + 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()}`); + await mkdir(tempRoot, { recursive: true }); + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ name: 'plain-node', dependencies: { lodash: '^4' } }) + ); + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework).toBeUndefined(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('populates framework.indicators when React is detected', async () => { + const analyzer = new ReactAnalyzer(); + const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `react-${randomUUID()}`); + await mkdir(tempRoot, { recursive: true }); + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ + name: 'react-app', + dependencies: { react: '^18', 'react-dom': '^18' }, + devDependencies: { '@types/react': '^18' } + }) + ); + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework?.type).toBe('react'); + expect(metadata.framework?.indicators).toContain('dep:react'); + expect(metadata.framework?.indicators).toContain('dep:react-dom'); + expect(metadata.framework?.indicators).toContain('dep:@types/react'); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); }); From 8e9cdee59406d8f13b495d48332973400a036426 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sun, 12 Apr 2026 11:46:55 +0200 Subject: [PATCH 2/2] fix: resolve PR #96 P1 regressions and add debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes two critical P1 regressions in framework detection: **React regression**: Plain JS React apps (CRA/Vite without TypeScript) produce only 2 indicators (dep:react, dep:react-dom) below the MIN_INDICATORS=3 threshold. Added two disk-based checks (disk:src-directory, disk:public-index-html) to bring them to 3. **Angular regression**: Angular library projects declare @angular/core in peerDependencies only (not in dependencies/devDependencies). Added peerDependencies to the allDeps merge, and added disk:ng-package-json indicator to identify library projects. **Debug logging**: selectFramework() now logs when framework claims are dropped below threshold, aiding production diagnosis. **Tests added**: - Plain JS React: react + react-dom + src directory → framework detected - Angular library: @angular/core in peerDependencies + ng-package.json → framework detected All 449 tests passing (2 new). TypeScript: clean. --- src/analyzers/angular/index.ts | 7 +++++++ src/analyzers/react/index.ts | 15 +++++++++++++++ src/core/indexer.ts | 18 ++++++++++++++++++ tests/indexer-metadata.test.ts | 27 +++++++++++++++++++++++++++ tests/react-analyzer.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 91 insertions(+) diff --git a/src/analyzers/angular/index.ts b/src/analyzers/angular/index.ts index 4f61b40..aa748ad 100644 --- a/src/analyzers/angular/index.ts +++ b/src/analyzers/angular/index.ts @@ -908,6 +908,7 @@ export class AngularAnalyzer implements FrameworkAnalyzer { // Extract Angular version and dependencies const allDeps = { + ...packageJson.peerDependencies, ...packageJson.dependencies, ...packageJson.devDependencies }; @@ -950,6 +951,12 @@ export class AngularAnalyzer implements FrameworkAnalyzer { } catch { /* absent */ } + try { + await fs.stat(path.join(rootPath, 'ng-package.json')); + indicators.push('disk:ng-package-json'); + } catch { + /* absent */ + } metadata.framework = { name: 'Angular', diff --git a/src/analyzers/react/index.ts b/src/analyzers/react/index.ts index bc6bbdd..317d296 100644 --- a/src/analyzers/react/index.ts +++ b/src/analyzers/react/index.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { promises as fs } from 'fs'; import { parse, type TSESTree } from '@typescript-eslint/typescript-estree'; import type { AnalysisResult, @@ -233,6 +234,20 @@ export class ReactAnalyzer implements FrameworkAnalyzer { if (packageInfo.allDependencies['@types/react']) indicators.push('dep:@types/react'); if (packageInfo.allDependencies['react-native']) indicators.push('dep:react-native'); + // Add disk-based indicators for plain JS React projects (CRA, Vite) + try { + await fs.stat(path.join(rootPath, 'src')); + indicators.push('disk:src-directory'); + } catch { + /* absent */ + } + try { + await fs.stat(path.join(rootPath, 'public', 'index.html')); + indicators.push('disk:public-index-html'); + } catch { + /* absent */ + } + // Only claim React when the react package is an actual dependency. if (indicators.includes('dep:react')) { metadata.framework = { diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 5aa0747..d6bb842 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -1283,6 +1283,24 @@ export class CodebaseIndexer { if (passes(base) && passes(incoming)) return base; // higher-priority analyzer wins if (passes(base)) return base; if (passes(incoming)) return incoming; + // Debug: log dropped framework claims to aid production diagnosis + const claimed = []; + if (base) + claimed.push( + `${(base as FrameworkInfo).type}(${(base as FrameworkInfo).indicators?.length ?? 0})` + ); + if (incoming) + claimed.push( + `${(incoming as FrameworkInfo).type}(${(incoming as FrameworkInfo).indicators?.length ?? 0})` + ); + if (claimed.length > 0) { + console.debug( + '[selectFramework] dropped %d claim(s) below MIN_INDICATORS=%d: %s', + claimed.length, + MIN_INDICATORS, + claimed.join(', ') + ); + } return undefined; } diff --git a/tests/indexer-metadata.test.ts b/tests/indexer-metadata.test.ts index 2380102..8705447 100644 --- a/tests/indexer-metadata.test.ts +++ b/tests/indexer-metadata.test.ts @@ -203,5 +203,32 @@ describe('CodebaseIndexer.detectMetadata', () => { expect(metadata.framework?.type).toBe('react'); expect(metadata.framework?.indicators).toContain('dep:react'); }); + + it('detects Angular library project with @angular/core in peerDependencies + ng-package.json', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'my-angular-lib', + peerDependencies: { + '@angular/core': '^17.0.0', + '@angular/common': '^17.0.0', + }, + devDependencies: { + '@angular/compiler-cli': '^17.0.0', + } + }) + ); + await fs.writeFile( + path.join(tempDir, 'ng-package.json'), + JSON.stringify({ lib: { entryFile: 'src/public-api.ts' } }) + ); + + const indexer = new CodebaseIndexer({ rootPath: tempDir }); + const metadata = await indexer.detectMetadata(); + + expect(metadata.framework?.type).toBe('angular'); + expect(metadata.framework?.indicators).toContain('dep:@angular/core'); + expect(metadata.framework?.indicators).toContain('disk:ng-package-json'); + }); }); }); diff --git a/tests/react-analyzer.test.ts b/tests/react-analyzer.test.ts index e1e695b..b3b9ef4 100644 --- a/tests/react-analyzer.test.ts +++ b/tests/react-analyzer.test.ts @@ -118,4 +118,28 @@ export class LegacyWidget extends Component { await rm(tempRoot, { recursive: true, force: true }); } }); + + it('detects plain JS React project with react + react-dom + src directory', async () => { + const analyzer = new ReactAnalyzer(); + const tempRoot = path.join(process.cwd(), 'tests', '.tmp', `react-${randomUUID()}`); + await mkdir(tempRoot, { recursive: true }); + await mkdir(path.join(tempRoot, 'src'), { recursive: true }); + try { + await writeFile( + path.join(tempRoot, 'package.json'), + JSON.stringify({ + name: 'plain-react-app', + dependencies: { react: '^18', 'react-dom': '^18' } + }) + ); + const metadata = await analyzer.detectCodebaseMetadata(tempRoot); + expect(metadata.framework?.type).toBe('react'); + expect(metadata.framework?.indicators).toContain('dep:react'); + expect(metadata.framework?.indicators).toContain('dep:react-dom'); + expect(metadata.framework?.indicators).toContain('disk:src-directory'); + expect(metadata.framework?.indicators?.length).toBeGreaterThanOrEqual(3); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); });