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
54 changes: 53 additions & 1 deletion src/analyzer/framework_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,58 @@ mod tests {
assert!(!react_tech.is_primary); // Should be false since Next.js is the meta-framework
}
}

#[test]
fn test_vite_react_is_not_misclassified_as_next() {
let language = DetectedLanguage {
name: "TypeScript".to_string(),
version: Some("18.0.0".to_string()),
confidence: 0.9,
files: vec![PathBuf::from("src/App.tsx")],
main_dependencies: vec![
"react".to_string(),
"react-dom".to_string(),
"vite".to_string(),
],
dev_dependencies: vec!["vite".to_string()],
package_manager: Some("npm".to_string()),
};

let config = AnalysisConfig::default();
let project_root = Path::new(".");

let technologies = detect_frameworks(project_root, &[language], &config).unwrap();

assert!(technologies.iter().any(|t| t.name == "Vite"));
assert!(technologies.iter().any(|t| t.name == "React"));
assert!(technologies.iter().all(|t| t.name != "Next.js"));
}

#[test]
fn test_tanstack_start_detection_over_structure_only() {
let language = DetectedLanguage {
name: "TypeScript".to_string(),
version: Some("18.0.0".to_string()),
confidence: 0.9,
files: vec![PathBuf::from("app/routes/index.tsx")],
main_dependencies: vec![
"@tanstack/react-start".to_string(),
"@tanstack/react-router".to_string(),
"react".to_string(),
"react-dom".to_string(),
],
dev_dependencies: vec![],
package_manager: Some("npm".to_string()),
};

let config = AnalysisConfig::default();
let project_root = Path::new(".");

let technologies = detect_frameworks(project_root, &[language], &config).unwrap();

assert!(technologies.iter().any(|t| t.name == "Tanstack Start"));
assert!(technologies.iter().all(|t| t.name != "Next.js"));
}

#[test]
fn test_python_fastapi_detection() {
Expand Down Expand Up @@ -248,4 +300,4 @@ mod tests {
assert!(async_runtimes.len() <= 1, "Should resolve conflicting async runtimes: found {:?}",
async_runtimes.iter().map(|t| &t.name).collect::<Vec<_>>());
}
}
}
49 changes: 38 additions & 11 deletions src/analyzer/frameworks/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ fn detect_by_config_files(language: &DetectedLanguage, rules: &[TechnologyRule])
}
}
// Check for Next.js config files
else if file_name == "next.config.js" || file_name == "next.config.ts" {
else if file_name.starts_with("next.config.") {
if let Some(nextjs_rule) = rules.iter().find(|r| r.name == "Next.js") {
detected.push(DetectedTechnology {
name: nextjs_rule.name.clone(),
Expand Down Expand Up @@ -221,27 +221,29 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
let mut has_encore_service_files = false;
let mut has_app_json = false;
let mut has_app_js_ts = false;

let mut has_next_config = false;
let mut has_tanstack_config = false;

// Check project directories
for file_path in &language.files {
if let Some(parent) = file_path.parent() {
let path_str = parent.to_string_lossy();
let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");

// Check for React Native structure
if path_str.contains("android") {
has_android_dir = true;
} else if path_str.contains("ios") {
has_ios_dir = true;
}
// Check for Next.js structure
else if path_str.contains("pages") {
else if has_path_component(parent, "pages") {
has_pages_dir = true;
} else if path_str.contains("app") && !path_str.contains("app.config") && !path_str.contains("encore.app") {
} else if has_path_component(parent, "app") && !file_name.contains("app.config") && !file_name.contains("encore.app") {
has_app_dir = true;
}
// Check for TanStack Start structure
else if path_str.contains("app/routes") {
else if has_app_routes(parent) {
has_app_routes_dir = true;
}
// Check for Encore structure
Expand All @@ -256,12 +258,22 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
} else if file_name == "App.js" || file_name == "App.tsx" {
has_app_js_ts = true;
}

// Configs (need to be recorded so structure checks can require them)
if file_name.starts_with("next.config.") {
has_next_config = true;
}
if file_name == "app.config.ts" || file_name == "app.config.js" || file_name.starts_with("vinxi.config") {
has_tanstack_config = true;
}
}
}

// Check if we have Expo dependencies
let has_expo_deps = language.main_dependencies.iter().any(|dep| dep == "expo" || dep == "react-native");

let has_next_dep = language.main_dependencies.iter().any(|dep| dep == "next" || dep.starts_with("next@"));
let has_tanstack_dep = language.main_dependencies.iter().any(|dep| dep.contains("tanstack/react-start") || dep.contains("tanstack-start") || dep.contains("vinxi"));

// Determine frameworks based on structure
if has_encore_app_file || has_encore_service_files {
// Likely Encore
Expand All @@ -277,7 +289,7 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
file_indicators: encore_rule.file_indicators.clone(),
});
}
} else if has_app_routes_dir {
} else if has_app_routes_dir && (has_tanstack_dep || has_tanstack_config) {
// Likely TanStack Start
if let Some(tanstack_rule) = rules.iter().find(|r| r.name == "Tanstack Start") {
detected.push(DetectedTechnology {
Expand All @@ -291,7 +303,7 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
file_indicators: tanstack_rule.file_indicators.clone(),
});
}
} else if has_pages_dir || has_app_dir {
} else if (has_pages_dir || has_app_dir) && (has_next_dep || has_next_config) {
// Likely Next.js
if let Some(nextjs_rule) = rules.iter().find(|r| r.name == "Next.js") {
detected.push(DetectedTechnology {
Expand Down Expand Up @@ -342,6 +354,21 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
}
}

/// Returns true if any path component exactly matches the target (avoids substring false positives like "apps/")
fn has_path_component(path: &Path, target: &str) -> bool {
path.components()
.any(|c| c.as_os_str().to_string_lossy() == target)
}

/// Detects the canonical TanStack Start layout app/routes (component-level, not substring)
fn has_app_routes(path: &Path) -> bool {
let components: Vec<String> = path
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
components.windows(2).any(|w| w[0] == "app" && w[1] == "routes")
}

/// New: Detect frameworks by analyzing source code patterns
fn detect_by_source_patterns(language: &DetectedLanguage, rules: &[TechnologyRule]) -> Option<Vec<DetectedTechnology>> {
let mut detected = Vec::new();
Expand Down Expand Up @@ -1287,4 +1314,4 @@ fn get_js_technology_rules() -> Vec<TechnologyRule> {
file_indicators: vec![],
},
]
}
}
48 changes: 31 additions & 17 deletions src/analyzer/language_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,26 +250,40 @@ fn analyze_javascript_project(
}
}

// Extract dependencies
if let Some(deps) = package_json.get("dependencies") {
if let Some(deps_obj) = deps.as_object() {
for (name, _) in deps_obj {
info.main_dependencies.push(name.clone());
}
// Extract dependencies (always include all buckets for framework detection)
if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) {
for (name, _) in deps {
info.main_dependencies.push(name.clone());
}
}

// Extract dev dependencies if enabled
if config.include_dev_dependencies {
if let Some(dev_deps) = package_json.get("devDependencies") {
if let Some(dev_deps_obj) = dev_deps.as_object() {
for (name, _) in dev_deps_obj {
info.dev_dependencies.push(name.clone());
}
}

// Frameworks like Vite/Remix/Next are often in devDependencies; always include
if let Some(dev_deps) = package_json.get("devDependencies").and_then(|d| d.as_object()) {
for (name, _) in dev_deps {
info.main_dependencies.push(name.clone());
info.dev_dependencies.push(name.clone());
}
}


// peerDependencies frequently carry framework identity (e.g., react-router)
if let Some(peer_deps) = package_json.get("peerDependencies").and_then(|d| d.as_object()) {
for (name, _) in peer_deps {
info.main_dependencies.push(name.clone());
}
}

// optional/bundled deps can also hold framework markers (rare but cheap to add)
if let Some(opt_deps) = package_json.get("optionalDependencies").and_then(|d| d.as_object()) {
for (name, _) in opt_deps {
info.main_dependencies.push(name.clone());
}
}
if let Some(bundle_deps) = package_json.get("bundledDependencies").and_then(|d| d.as_array()) {
for dep in bundle_deps.iter().filter_map(|d| d.as_str()) {
info.main_dependencies.push(dep.to_string());
}
}

info.confidence = 0.95; // High confidence with manifest
}
}
Expand Down Expand Up @@ -1156,4 +1170,4 @@ black>=23.0.0
assert_eq!(languages[0].name, "Python");
assert!(languages[0].confidence > 0.8);
}
}
}
10 changes: 10 additions & 0 deletions tests/fixtures/js_frameworks/angular/angular.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 1,
"projects": {
"fixture-angular": {
"root": "",
"sourceRoot": "src",
"projectType": "application"
}
}
}
7 changes: 7 additions & 0 deletions tests/fixtures/js_frameworks/angular/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-angular",
"private": true,
"dependencies": {
"@angular/core": "17.0.0"
}
}
4 changes: 4 additions & 0 deletions tests/fixtures/js_frameworks/angular/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { enableProdMode } from '@angular/core';

enableProdMode();
console.log('Angular Fixture');
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/astro/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
7 changes: 7 additions & 0 deletions tests/fixtures/js_frameworks/astro/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-astro",
"private": true,
"dependencies": {
"astro": "4.0.0"
}
}
4 changes: 4 additions & 0 deletions tests/fixtures/js_frameworks/astro/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
// Astro fixture page
---
<h1>Astro Fixture</h1>
5 changes: 5 additions & 0 deletions tests/fixtures/js_frameworks/expo/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Text } from 'react-native';

export default function App() {
return <Text>Expo Fixture</Text>;
}
6 changes: 6 additions & 0 deletions tests/fixtures/js_frameworks/expo/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"expo": {
"name": "Fixture Expo",
"slug": "fixture-expo"
}
}
9 changes: 9 additions & 0 deletions tests/fixtures/js_frameworks/expo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "fixture-expo",
"private": true,
"dependencies": {
"expo": "50.0.0",
"react": "18.2.0",
"react-native": "0.73.0"
}
}
7 changes: 7 additions & 0 deletions tests/fixtures/js_frameworks/express/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-express",
"private": true,
"dependencies": {
"express": "4.19.0"
}
}
4 changes: 4 additions & 0 deletions tests/fixtures/js_frameworks/express/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const express = require('express');
const app = express();
app.get('/', (_req, res) => res.send('Express Fixture'));
module.exports = app;
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/nextjs/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
reactStrictMode: true,
};
9 changes: 9 additions & 0 deletions tests/fixtures/js_frameworks/nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "fixture-nextjs",
"private": true,
"dependencies": {
"next": "14.1.0",
"react": "18.2.0",
"react-dom": "18.2.0"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/nextjs/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Home() {
return <h1>Next.js Fixture</h1>;
}
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default defineNuxtConfig({
ssr: true,
});
7 changes: 7 additions & 0 deletions tests/fixtures/js_frameworks/nuxt/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-nuxt",
"private": true,
"dependencies": {
"nuxt": "3.11.0"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/nuxt/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>Nuxt Fixture</h1>
</template>
10 changes: 10 additions & 0 deletions tests/fixtures/js_frameworks/react-router-spa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "fixture-react-router-spa",
"private": true,
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router": "7.0.0",
"react-router-dom": "7.0.0"
}
}
12 changes: 12 additions & 0 deletions tests/fixtures/js_frameworks/react-router-spa/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const App = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<h1>React Router SPA Fixture</h1>} />
</Routes>
</BrowserRouter>
);

createRoot(document.getElementById('root')!).render(<App />);
8 changes: 8 additions & 0 deletions tests/fixtures/js_frameworks/solidstart/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-solidstart",
"private": true,
"dependencies": {
"solid-start": "1.0.0",
"solid-js": "1.8.0"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/solidstart/solid.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'solid-start/config';

export default defineConfig({});
3 changes: 3 additions & 0 deletions tests/fixtures/js_frameworks/solidstart/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function SolidStartIndex() {
return <h1>SolidStart Fixture</h1>;
}
Loading