Skip to content

Commit ae649dd

Browse files
authored
fix(metadata): prevent framework misclassification in codebase detection (#96)
* 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) * fix: resolve PR #96 P1 regressions and add debug logging 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.
1 parent 8650c0a commit ae649dd

8 files changed

Lines changed: 363 additions & 56 deletions

File tree

src/analyzers/angular/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,7 @@ export class AngularAnalyzer implements FrameworkAnalyzer {
908908

909909
// Extract Angular version and dependencies
910910
const allDeps = {
911+
...packageJson.peerDependencies,
911912
...packageJson.dependencies,
912913
...packageJson.devDependencies
913914
};
@@ -933,14 +934,39 @@ export class AngularAnalyzer implements FrameworkAnalyzer {
933934
if (allDeps['jest']) testingFrameworks.push('Jest');
934935

935936
if (angularCoreVersion) {
937+
// Collect evidence indicators for the merge threshold check.
938+
const indicators: string[] = ['dep:@angular/core'];
939+
if (allDeps['@angular/common']) indicators.push('dep:@angular/common');
940+
if (allDeps['@angular/compiler-cli']) indicators.push('dep:@angular/compiler-cli');
941+
if (allDeps['@angular/cli']) indicators.push('dep:@angular/cli');
942+
try {
943+
await fs.stat(path.join(rootPath, 'angular.json'));
944+
indicators.push('disk:angular-json');
945+
} catch {
946+
/* absent */
947+
}
948+
try {
949+
await fs.stat(path.join(rootPath, 'tsconfig.app.json'));
950+
indicators.push('disk:tsconfig-app');
951+
} catch {
952+
/* absent */
953+
}
954+
try {
955+
await fs.stat(path.join(rootPath, 'ng-package.json'));
956+
indicators.push('disk:ng-package-json');
957+
} catch {
958+
/* absent */
959+
}
960+
936961
metadata.framework = {
937962
name: 'Angular',
938963
version: angularCoreVersion.replace(/[\^~]/, ''),
939964
type: 'angular',
940965
variant: 'unknown', // Will be determined during analysis
941966
stateManagement,
942967
uiLibraries,
943-
testingFrameworks
968+
testingFrameworks,
969+
indicators
944970
};
945971
}
946972

src/analyzers/nextjs/index.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -198,33 +198,53 @@ export class NextJsAnalyzer implements FrameworkAnalyzer {
198198
category: categorizeDependency(name)
199199
})
200200
);
201-
metadata.framework = {
202-
name: 'Next.js',
203-
version: normalizeAnalyzerVersion(packageInfo.allDependencies.next),
204-
type: 'nextjs',
205-
variant: getFrameworkVariant(routerPresence),
206-
stateManagement: detectDependencyList(packageInfo.allDependencies, [
207-
['@reduxjs/toolkit', 'redux'],
208-
['redux', 'redux'],
209-
['zustand', 'zustand'],
210-
['jotai', 'jotai'],
211-
['recoil', 'recoil'],
212-
['mobx', 'mobx']
213-
]),
214-
uiLibraries: detectDependencyList(packageInfo.allDependencies, [
215-
['tailwindcss', 'Tailwind'],
216-
['@mui/material', 'MUI'],
217-
['styled-components', 'styled-components'],
218-
['@radix-ui/react-slot', 'Radix UI']
219-
]),
220-
testingFrameworks: detectDependencyList(packageInfo.allDependencies, [
221-
['vitest', 'Vitest'],
222-
['jest', 'Jest'],
223-
['@testing-library/react', 'Testing Library'],
224-
['playwright', 'Playwright'],
225-
['cypress', 'Cypress']
226-
])
227-
};
201+
202+
// Collect evidence indicators before claiming framework.
203+
const indicators: string[] = [];
204+
if (packageInfo.allDependencies.next) indicators.push('dep:next');
205+
if (packageInfo.allDependencies.react) indicators.push('dep:react');
206+
if (packageInfo.allDependencies['react-dom']) indicators.push('dep:react-dom');
207+
if (routerPresence.hasAppRouter) indicators.push('disk:app-router');
208+
if (routerPresence.hasPagesRouter) indicators.push('disk:pages-router');
209+
const nextConfigCandidates = [
210+
path.join(rootPath, 'next.config.js'),
211+
path.join(rootPath, 'next.config.ts'),
212+
path.join(rootPath, 'next.config.mjs'),
213+
path.join(rootPath, 'next.config.cjs')
214+
];
215+
if (await anyExists(nextConfigCandidates)) indicators.push('disk:next-config');
216+
217+
// Only claim Next.js when the next package is an actual dependency.
218+
if (indicators.includes('dep:next')) {
219+
metadata.framework = {
220+
name: 'Next.js',
221+
version: normalizeAnalyzerVersion(packageInfo.allDependencies.next),
222+
type: 'nextjs',
223+
variant: getFrameworkVariant(routerPresence),
224+
stateManagement: detectDependencyList(packageInfo.allDependencies, [
225+
['@reduxjs/toolkit', 'redux'],
226+
['redux', 'redux'],
227+
['zustand', 'zustand'],
228+
['jotai', 'jotai'],
229+
['recoil', 'recoil'],
230+
['mobx', 'mobx']
231+
]),
232+
uiLibraries: detectDependencyList(packageInfo.allDependencies, [
233+
['tailwindcss', 'Tailwind'],
234+
['@mui/material', 'MUI'],
235+
['styled-components', 'styled-components'],
236+
['@radix-ui/react-slot', 'Radix UI']
237+
]),
238+
testingFrameworks: detectDependencyList(packageInfo.allDependencies, [
239+
['vitest', 'Vitest'],
240+
['jest', 'Jest'],
241+
['@testing-library/react', 'Testing Library'],
242+
['playwright', 'Playwright'],
243+
['cypress', 'Cypress']
244+
]),
245+
indicators
246+
};
247+
}
228248
metadata.customMetadata = {
229249
nextjs: routerPresence
230250
};

src/analyzers/react/index.ts

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'path';
2+
import { promises as fs } from 'fs';
23
import { parse, type TSESTree } from '@typescript-eslint/typescript-estree';
34
import type {
45
AnalysisResult,
@@ -225,33 +226,59 @@ export class ReactAnalyzer implements FrameworkAnalyzer {
225226
category: categorizeDependency(name)
226227
})
227228
);
228-
metadata.framework = {
229-
name: 'React',
230-
version: normalizeAnalyzerVersion(packageInfo.allDependencies.react),
231-
type: 'react',
232-
variant: 'unknown',
233-
stateManagement: detectDependencyList(packageInfo.allDependencies, [
234-
['@reduxjs/toolkit', 'redux'],
235-
['redux', 'redux'],
236-
['zustand', 'zustand'],
237-
['jotai', 'jotai'],
238-
['recoil', 'recoil'],
239-
['mobx', 'mobx']
240-
]),
241-
uiLibraries: detectDependencyList(packageInfo.allDependencies, [
242-
['tailwindcss', 'Tailwind'],
243-
['@mui/material', 'MUI'],
244-
['styled-components', 'styled-components'],
245-
['@radix-ui/react-slot', 'Radix UI']
246-
]),
247-
testingFrameworks: detectDependencyList(packageInfo.allDependencies, [
248-
['vitest', 'Vitest'],
249-
['jest', 'Jest'],
250-
['@testing-library/react', 'Testing Library'],
251-
['playwright', 'Playwright'],
252-
['cypress', 'Cypress']
253-
])
254-
};
229+
230+
// Collect evidence indicators before claiming framework.
231+
const indicators: string[] = [];
232+
if (packageInfo.allDependencies.react) indicators.push('dep:react');
233+
if (packageInfo.allDependencies['react-dom']) indicators.push('dep:react-dom');
234+
if (packageInfo.allDependencies['@types/react']) indicators.push('dep:@types/react');
235+
if (packageInfo.allDependencies['react-native']) indicators.push('dep:react-native');
236+
237+
// Add disk-based indicators for plain JS React projects (CRA, Vite)
238+
try {
239+
await fs.stat(path.join(rootPath, 'src'));
240+
indicators.push('disk:src-directory');
241+
} catch {
242+
/* absent */
243+
}
244+
try {
245+
await fs.stat(path.join(rootPath, 'public', 'index.html'));
246+
indicators.push('disk:public-index-html');
247+
} catch {
248+
/* absent */
249+
}
250+
251+
// Only claim React when the react package is an actual dependency.
252+
if (indicators.includes('dep:react')) {
253+
metadata.framework = {
254+
name: 'React',
255+
version: normalizeAnalyzerVersion(packageInfo.allDependencies.react),
256+
type: 'react',
257+
variant: 'unknown',
258+
stateManagement: detectDependencyList(packageInfo.allDependencies, [
259+
['@reduxjs/toolkit', 'redux'],
260+
['redux', 'redux'],
261+
['zustand', 'zustand'],
262+
['jotai', 'jotai'],
263+
['recoil', 'recoil'],
264+
['mobx', 'mobx']
265+
]),
266+
uiLibraries: detectDependencyList(packageInfo.allDependencies, [
267+
['tailwindcss', 'Tailwind'],
268+
['@mui/material', 'MUI'],
269+
['styled-components', 'styled-components'],
270+
['@radix-ui/react-slot', 'Radix UI']
271+
]),
272+
testingFrameworks: detectDependencyList(packageInfo.allDependencies, [
273+
['vitest', 'Vitest'],
274+
['jest', 'Jest'],
275+
['@testing-library/react', 'Testing Library'],
276+
['playwright', 'Playwright'],
277+
['cypress', 'Cypress']
278+
]),
279+
indicators
280+
};
281+
}
255282
} catch (error) {
256283
if (!isFileNotFoundError(error)) {
257284
console.warn('Failed to read React project metadata:', error);

src/core/indexer.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ignore from 'ignore';
1111
import {
1212
CodebaseMetadata,
1313
CodeChunk,
14+
FrameworkInfo,
1415
IndexingProgress,
1516
IndexingStats,
1617
IndexingPhase,
@@ -1243,7 +1244,7 @@ export class CodebaseIndexer {
12431244
rootPath: incoming.rootPath || base.rootPath,
12441245
languages: [...new Set([...base.languages, ...incoming.languages])], // Merge and deduplicate
12451246
dependencies: this.mergeDependencies(base.dependencies, incoming.dependencies),
1246-
framework: base.framework || incoming.framework, // Framework from higher priority analyzer wins
1247+
framework: this.selectFramework(base.framework, incoming.framework),
12471248
architecture: {
12481249
type: incoming.architecture?.type || base.architecture.type,
12491250
layers: this.mergeLayers(base.architecture.layers, incoming.architecture?.layers),
@@ -1265,6 +1266,44 @@ export class CodebaseIndexer {
12651266
};
12661267
}
12671268

1269+
/**
1270+
* Select the best framework claim from two candidates.
1271+
* Requires at least MIN_INDICATORS evidence signals — prevents analyzers from claiming
1272+
* a framework when only one weak signal is present (e.g. a single import).
1273+
* Priority-order callers (highest priority first) ensure the first passing candidate wins.
1274+
*/
1275+
private selectFramework(
1276+
base: FrameworkInfo | undefined,
1277+
incoming: FrameworkInfo | undefined
1278+
): FrameworkInfo | undefined {
1279+
const MIN_INDICATORS = 3;
1280+
const passes = (f: FrameworkInfo | undefined): f is FrameworkInfo =>
1281+
!!f && (f.indicators?.length ?? 0) >= MIN_INDICATORS;
1282+
1283+
if (passes(base) && passes(incoming)) return base; // higher-priority analyzer wins
1284+
if (passes(base)) return base;
1285+
if (passes(incoming)) return incoming;
1286+
// Debug: log dropped framework claims to aid production diagnosis
1287+
const claimed = [];
1288+
if (base)
1289+
claimed.push(
1290+
`${(base as FrameworkInfo).type}(${(base as FrameworkInfo).indicators?.length ?? 0})`
1291+
);
1292+
if (incoming)
1293+
claimed.push(
1294+
`${(incoming as FrameworkInfo).type}(${(incoming as FrameworkInfo).indicators?.length ?? 0})`
1295+
);
1296+
if (claimed.length > 0) {
1297+
console.debug(
1298+
'[selectFramework] dropped %d claim(s) below MIN_INDICATORS=%d: %s',
1299+
claimed.length,
1300+
MIN_INDICATORS,
1301+
claimed.join(', ')
1302+
);
1303+
}
1304+
return undefined;
1305+
}
1306+
12681307
private mergeDependencies(base: Dependency[], incoming: Dependency[]): Dependency[] {
12691308
const seen = new Set(base.map((d) => d.name));
12701309
const result = [...base];

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ export interface FrameworkInfo {
211211
stateManagement?: string[]; // 'ngrx', 'redux', 'zustand', 'pinia', etc.
212212
uiLibraries?: string[];
213213
testingFrameworks?: string[];
214+
/** Enumerated evidence signals that triggered this framework claim (e.g. 'dep:next', 'disk:app-router'). */
215+
indicators?: readonly string[];
214216
}
215217

216218
export interface LanguageInfo {

tests/indexer-metadata.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,92 @@ describe('CodebaseIndexer.detectMetadata', () => {
143143
expect(typeof metadata.customMetadata).toBe('object');
144144
});
145145
});
146+
147+
describe('framework misclassification guards', () => {
148+
it('does not claim framework for plain Node project', async () => {
149+
await fs.writeFile(
150+
path.join(tempDir, 'package.json'),
151+
JSON.stringify({ name: 'plain-node', dependencies: { zod: '^3' } })
152+
);
153+
154+
const indexer = new CodebaseIndexer({ rootPath: tempDir });
155+
const metadata = await indexer.detectMetadata();
156+
157+
expect(metadata.framework).toBeUndefined();
158+
});
159+
160+
it('drops React claim when indicators are below threshold', async () => {
161+
// react alone is only 1 indicator — should not meet the >=3 threshold
162+
await fs.writeFile(
163+
path.join(tempDir, 'package.json'),
164+
JSON.stringify({ name: 'thin-react', dependencies: { react: '^18' } })
165+
);
166+
167+
const indexer = new CodebaseIndexer({ rootPath: tempDir });
168+
const metadata = await indexer.detectMetadata();
169+
170+
expect(metadata.framework).toBeUndefined();
171+
});
172+
173+
it('preserves Next.js preference over React when both pass threshold', async () => {
174+
// next + react + react-dom + app/ = 4 Next.js indicators; react + react-dom = 2 React indicators
175+
await fs.writeFile(
176+
path.join(tempDir, 'package.json'),
177+
JSON.stringify({
178+
name: 'next-project',
179+
dependencies: { next: '^14.1.0', react: '^18.2.0', 'react-dom': '^18.2.0' },
180+
})
181+
);
182+
await fs.mkdir(path.join(tempDir, 'app'), { recursive: true });
183+
184+
const indexer = new CodebaseIndexer({ rootPath: tempDir });
185+
const metadata = await indexer.detectMetadata();
186+
187+
expect(metadata.framework?.type).toBe('nextjs');
188+
});
189+
190+
it('detects React when sufficient indicators are present', async () => {
191+
await fs.writeFile(
192+
path.join(tempDir, 'package.json'),
193+
JSON.stringify({
194+
name: 'react-app',
195+
dependencies: { react: '^18', 'react-dom': '^18' },
196+
devDependencies: { '@types/react': '^18' },
197+
})
198+
);
199+
200+
const indexer = new CodebaseIndexer({ rootPath: tempDir });
201+
const metadata = await indexer.detectMetadata();
202+
203+
expect(metadata.framework?.type).toBe('react');
204+
expect(metadata.framework?.indicators).toContain('dep:react');
205+
});
206+
207+
it('detects Angular library project with @angular/core in peerDependencies + ng-package.json', async () => {
208+
await fs.writeFile(
209+
path.join(tempDir, 'package.json'),
210+
JSON.stringify({
211+
name: 'my-angular-lib',
212+
peerDependencies: {
213+
'@angular/core': '^17.0.0',
214+
'@angular/common': '^17.0.0',
215+
},
216+
devDependencies: {
217+
'@angular/compiler-cli': '^17.0.0',
218+
}
219+
})
220+
);
221+
await fs.writeFile(
222+
path.join(tempDir, 'ng-package.json'),
223+
JSON.stringify({ lib: { entryFile: 'src/public-api.ts' } })
224+
);
225+
226+
const indexer = new CodebaseIndexer({ rootPath: tempDir });
227+
const metadata = await indexer.detectMetadata();
228+
229+
expect(metadata.framework?.type).toBe('angular');
230+
expect(metadata.framework?.indicators).toContain('dep:@angular/core');
231+
expect(metadata.framework?.indicators).toContain('disk:ng-package-json');
232+
});
233+
});
146234
});

0 commit comments

Comments
 (0)