Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/analyzers/angular/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,7 @@ export class AngularAnalyzer implements FrameworkAnalyzer {

// Extract Angular version and dependencies
const allDeps = {
...packageJson.peerDependencies,
...packageJson.dependencies,
...packageJson.devDependencies
};
Expand All @@ -933,14 +934,39 @@ 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 */
}
Comment on lines +938 to +953
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Angular library projects may silently lose framework detection

dep:@angular/core is the only guaranteed indicator in this block — the rest are conditional. An Angular library project that lists @angular/core in peerDependencies (no @angular/common, and no CLI-generated angular.json or tsconfig.app.json) ends up with just 1 indicator, well below MIN_INDICATORS = 3. Unlike the old base.framework || incoming.framework path, selectFramework will silently return undefined.

Before this PR, angularCoreVersion alone was sufficient to classify a project as Angular (which is a much tighter guard than just dep:react). The same threshold now uniformly penalises Angular, which had a more reliable single-dep signal to begin with.

Suggested mitigations:

  • Add disk checks for ng-package.json (Angular library projects) and tsconfig.json so library projects get extra indicators.
  • Or use a per-framework threshold so Angular (which already has a strong one-dep guard) can pass at 2 indicators.

try {
await fs.stat(path.join(rootPath, 'ng-package.json'));
indicators.push('disk:ng-package-json');
} catch {
/* absent */
}

metadata.framework = {
name: 'Angular',
version: angularCoreVersion.replace(/[\^~]/, ''),
type: 'angular',
variant: 'unknown', // Will be determined during analysis
stateManagement,
uiLibraries,
testingFrameworks
testingFrameworks,
indicators
};
}

Expand Down
74 changes: 47 additions & 27 deletions src/analyzers/nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
81 changes: 54 additions & 27 deletions src/analyzers/react/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -225,33 +226,59 @@ 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');
Comment on lines +231 to +235
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Plain JS React apps silently lose framework detection

The React analyzer collects only four dep-based indicators (dep:react, dep:react-dom, dep:@types/react, dep:react-native). A vanilla JavaScript React app that has react + react-dom but no TypeScript (@types/react) and no React Native ends up with exactly 2 indicators — one below the MIN_INDICATORS = 3 threshold in selectFramework. The framework claim is silently dropped in the merge step, even though react + react-dom together are a very strong signal of a real React project.

Before this PR the old base.framework || incoming.framework path would have kept the claim, so this is a regression for a common project shape. The test suite only covers the 1-indicator case (react alone) and the 3-indicator case (react + react-dom + @types/react); the 2-indicator case is untested and broken.

Possible fixes:

  1. Add at least one disk-based indicator (e.g. presence of src/App.jsx, src/App.tsx, or public/index.html) so a browser React app can always reach 3 signals.
  2. Lower the threshold for the React analyzer to 2, using a per-framework constant instead of the shared MIN_INDICATORS.
  3. Treat dep:react + dep:react-dom together as meeting the bar (the combination is browser-specific and rarely transitive-only).


// 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 = {
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);
Expand Down
41 changes: 40 additions & 1 deletion src/core/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ignore from 'ignore';
import {
CodebaseMetadata,
CodeChunk,
FrameworkInfo,
IndexingProgress,
IndexingStats,
IndexingPhase,
Expand Down Expand Up @@ -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),
Expand All @@ -1265,6 +1266,44 @@ 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;
// 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;
}
Comment on lines +1278 to +1305
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent framework drop is hard to diagnose

When selectFramework returns undefined because neither candidate crosses the threshold, there is no log message. If a downstream caller gets metadata.framework === undefined on a project they know is Angular or React, there is no way to see which analyzer ran, how many indicators were collected, or why the claim was rejected.

A single console.debug emitting something like "[selectFramework] dropped framework claim — only N indicators (threshold: MIN_INDICATORS)" would make future misclassification reports trivially diagnosable.

Suggested change
): 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 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;
const dropped = base ?? incoming;
if (dropped) {
console.debug(
`[selectFramework] dropped ${dropped.name} claim — ${dropped.indicators?.length ?? 0} indicators (threshold: ${MIN_INDICATORS})`
);
}
return undefined;
}


private mergeDependencies(base: Dependency[], incoming: Dependency[]): Dependency[] {
const seen = new Set(base.map((d) => d.name));
const result = [...base];
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions tests/indexer-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,92 @@ 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 });
Comment on lines +155 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test coverage for the 2-indicator React case

The comment on this test says "react alone is only 1 indicator — should not meet the >=3 threshold." The symmetric case — react + react-dom (2 indicators, no @types/react) — is not tested and is the common shape of a plain JavaScript React app. Adding a case like the one below would surface the regression described in react/index.ts:

it('drops React claim for react + react-dom without @types/react (2 indicators)', async () => {
    await fs.writeFile(
        path.join(tempDir, 'package.json'),
        JSON.stringify({ name: 'js-react', dependencies: { react: '^18', 'react-dom': '^18' } })
    );
    const indexer = new CodebaseIndexer({ rootPath: tempDir });
    const metadata = await indexer.detectMetadata();
    // Decide: should this be undefined (current) or 'react' (intended)?
    expect(metadata.framework?.type).toBe('react'); // currently fails
});

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');
});

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');
});
});
});
Loading
Loading