diff --git a/src/analyzer/framework_detector.rs b/src/analyzer/framework_detector.rs index 32193f14..3b4ce79c 100644 --- a/src/analyzer/framework_detector.rs +++ b/src/analyzer/framework_detector.rs @@ -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() { @@ -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::>()); } -} \ No newline at end of file +} diff --git a/src/analyzer/frameworks/javascript.rs b/src/analyzer/frameworks/javascript.rs index 8d3d5f2d..3eda1715 100644 --- a/src/analyzer/frameworks/javascript.rs +++ b/src/analyzer/frameworks/javascript.rs @@ -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(), @@ -221,13 +221,15 @@ 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; @@ -235,13 +237,13 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR 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 @@ -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 @@ -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 { @@ -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 { @@ -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 = 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> { let mut detected = Vec::new(); @@ -1287,4 +1314,4 @@ fn get_js_technology_rules() -> Vec { file_indicators: vec![], }, ] -} \ No newline at end of file +} diff --git a/src/analyzer/language_detector.rs b/src/analyzer/language_detector.rs index 42307351..45e8f4e7 100644 --- a/src/analyzer/language_detector.rs +++ b/src/analyzer/language_detector.rs @@ -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 } } @@ -1156,4 +1170,4 @@ black>=23.0.0 assert_eq!(languages[0].name, "Python"); assert!(languages[0].confidence > 0.8); } -} \ No newline at end of file +} diff --git a/tests/fixtures/js_frameworks/angular/angular.json b/tests/fixtures/js_frameworks/angular/angular.json new file mode 100644 index 00000000..54bdc2c7 --- /dev/null +++ b/tests/fixtures/js_frameworks/angular/angular.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "projects": { + "fixture-angular": { + "root": "", + "sourceRoot": "src", + "projectType": "application" + } + } +} diff --git a/tests/fixtures/js_frameworks/angular/package.json b/tests/fixtures/js_frameworks/angular/package.json new file mode 100644 index 00000000..6662b31d --- /dev/null +++ b/tests/fixtures/js_frameworks/angular/package.json @@ -0,0 +1,7 @@ +{ + "name": "fixture-angular", + "private": true, + "dependencies": { + "@angular/core": "17.0.0" + } +} diff --git a/tests/fixtures/js_frameworks/angular/src/main.ts b/tests/fixtures/js_frameworks/angular/src/main.ts new file mode 100644 index 00000000..0f04070f --- /dev/null +++ b/tests/fixtures/js_frameworks/angular/src/main.ts @@ -0,0 +1,4 @@ +import { enableProdMode } from '@angular/core'; + +enableProdMode(); +console.log('Angular Fixture'); diff --git a/tests/fixtures/js_frameworks/astro/astro.config.mjs b/tests/fixtures/js_frameworks/astro/astro.config.mjs new file mode 100644 index 00000000..86dbfb92 --- /dev/null +++ b/tests/fixtures/js_frameworks/astro/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/tests/fixtures/js_frameworks/astro/package.json b/tests/fixtures/js_frameworks/astro/package.json new file mode 100644 index 00000000..c98cd17a --- /dev/null +++ b/tests/fixtures/js_frameworks/astro/package.json @@ -0,0 +1,7 @@ +{ + "name": "fixture-astro", + "private": true, + "dependencies": { + "astro": "4.0.0" + } +} diff --git a/tests/fixtures/js_frameworks/astro/src/pages/index.astro b/tests/fixtures/js_frameworks/astro/src/pages/index.astro new file mode 100644 index 00000000..d465a080 --- /dev/null +++ b/tests/fixtures/js_frameworks/astro/src/pages/index.astro @@ -0,0 +1,4 @@ +--- +// Astro fixture page +--- +

Astro Fixture

diff --git a/tests/fixtures/js_frameworks/expo/App.tsx b/tests/fixtures/js_frameworks/expo/App.tsx new file mode 100644 index 00000000..3128b419 --- /dev/null +++ b/tests/fixtures/js_frameworks/expo/App.tsx @@ -0,0 +1,5 @@ +import { Text } from 'react-native'; + +export default function App() { + return Expo Fixture; +} diff --git a/tests/fixtures/js_frameworks/expo/app.json b/tests/fixtures/js_frameworks/expo/app.json new file mode 100644 index 00000000..8f5e037e --- /dev/null +++ b/tests/fixtures/js_frameworks/expo/app.json @@ -0,0 +1,6 @@ +{ + "expo": { + "name": "Fixture Expo", + "slug": "fixture-expo" + } +} diff --git a/tests/fixtures/js_frameworks/expo/package.json b/tests/fixtures/js_frameworks/expo/package.json new file mode 100644 index 00000000..900158cd --- /dev/null +++ b/tests/fixtures/js_frameworks/expo/package.json @@ -0,0 +1,9 @@ +{ + "name": "fixture-expo", + "private": true, + "dependencies": { + "expo": "50.0.0", + "react": "18.2.0", + "react-native": "0.73.0" + } +} diff --git a/tests/fixtures/js_frameworks/express/package.json b/tests/fixtures/js_frameworks/express/package.json new file mode 100644 index 00000000..d2767db1 --- /dev/null +++ b/tests/fixtures/js_frameworks/express/package.json @@ -0,0 +1,7 @@ +{ + "name": "fixture-express", + "private": true, + "dependencies": { + "express": "4.19.0" + } +} diff --git a/tests/fixtures/js_frameworks/express/server.js b/tests/fixtures/js_frameworks/express/server.js new file mode 100644 index 00000000..75609fc2 --- /dev/null +++ b/tests/fixtures/js_frameworks/express/server.js @@ -0,0 +1,4 @@ +const express = require('express'); +const app = express(); +app.get('/', (_req, res) => res.send('Express Fixture')); +module.exports = app; diff --git a/tests/fixtures/js_frameworks/nextjs/next.config.js b/tests/fixtures/js_frameworks/nextjs/next.config.js new file mode 100644 index 00000000..da1bb770 --- /dev/null +++ b/tests/fixtures/js_frameworks/nextjs/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + reactStrictMode: true, +}; diff --git a/tests/fixtures/js_frameworks/nextjs/package.json b/tests/fixtures/js_frameworks/nextjs/package.json new file mode 100644 index 00000000..b81e0656 --- /dev/null +++ b/tests/fixtures/js_frameworks/nextjs/package.json @@ -0,0 +1,9 @@ +{ + "name": "fixture-nextjs", + "private": true, + "dependencies": { + "next": "14.1.0", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/js_frameworks/nextjs/pages/index.tsx b/tests/fixtures/js_frameworks/nextjs/pages/index.tsx new file mode 100644 index 00000000..2dadb29a --- /dev/null +++ b/tests/fixtures/js_frameworks/nextjs/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return

Next.js Fixture

; +} diff --git a/tests/fixtures/js_frameworks/nuxt/nuxt.config.ts b/tests/fixtures/js_frameworks/nuxt/nuxt.config.ts new file mode 100644 index 00000000..1b56c9c1 --- /dev/null +++ b/tests/fixtures/js_frameworks/nuxt/nuxt.config.ts @@ -0,0 +1,3 @@ +export default defineNuxtConfig({ + ssr: true, +}); diff --git a/tests/fixtures/js_frameworks/nuxt/package.json b/tests/fixtures/js_frameworks/nuxt/package.json new file mode 100644 index 00000000..9d072ec2 --- /dev/null +++ b/tests/fixtures/js_frameworks/nuxt/package.json @@ -0,0 +1,7 @@ +{ + "name": "fixture-nuxt", + "private": true, + "dependencies": { + "nuxt": "3.11.0" + } +} diff --git a/tests/fixtures/js_frameworks/nuxt/pages/index.vue b/tests/fixtures/js_frameworks/nuxt/pages/index.vue new file mode 100644 index 00000000..2740d8a8 --- /dev/null +++ b/tests/fixtures/js_frameworks/nuxt/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/tests/fixtures/js_frameworks/react-router-spa/package.json b/tests/fixtures/js_frameworks/react-router-spa/package.json new file mode 100644 index 00000000..1fe5aa47 --- /dev/null +++ b/tests/fixtures/js_frameworks/react-router-spa/package.json @@ -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" + } +} diff --git a/tests/fixtures/js_frameworks/react-router-spa/src/index.tsx b/tests/fixtures/js_frameworks/react-router-spa/src/index.tsx new file mode 100644 index 00000000..3db1363d --- /dev/null +++ b/tests/fixtures/js_frameworks/react-router-spa/src/index.tsx @@ -0,0 +1,12 @@ +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; + +const App = () => ( + + + React Router SPA Fixture} /> + + +); + +createRoot(document.getElementById('root')!).render(); diff --git a/tests/fixtures/js_frameworks/solidstart/package.json b/tests/fixtures/js_frameworks/solidstart/package.json new file mode 100644 index 00000000..9132a899 --- /dev/null +++ b/tests/fixtures/js_frameworks/solidstart/package.json @@ -0,0 +1,8 @@ +{ + "name": "fixture-solidstart", + "private": true, + "dependencies": { + "solid-start": "1.0.0", + "solid-js": "1.8.0" + } +} diff --git a/tests/fixtures/js_frameworks/solidstart/solid.config.ts b/tests/fixtures/js_frameworks/solidstart/solid.config.ts new file mode 100644 index 00000000..3b4be395 --- /dev/null +++ b/tests/fixtures/js_frameworks/solidstart/solid.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'solid-start/config'; + +export default defineConfig({}); diff --git a/tests/fixtures/js_frameworks/solidstart/src/routes/index.tsx b/tests/fixtures/js_frameworks/solidstart/src/routes/index.tsx new file mode 100644 index 00000000..696c192f --- /dev/null +++ b/tests/fixtures/js_frameworks/solidstart/src/routes/index.tsx @@ -0,0 +1,3 @@ +export default function SolidStartIndex() { + return

SolidStart Fixture

; +} diff --git a/tests/fixtures/js_frameworks/sveltekit/package.json b/tests/fixtures/js_frameworks/sveltekit/package.json new file mode 100644 index 00000000..e4edcb7e --- /dev/null +++ b/tests/fixtures/js_frameworks/sveltekit/package.json @@ -0,0 +1,8 @@ +{ + "name": "fixture-sveltekit", + "private": true, + "dependencies": { + "@sveltejs/kit": "1.30.0", + "svelte": "4.2.0" + } +} diff --git a/tests/fixtures/js_frameworks/sveltekit/src/routes/+page.svelte b/tests/fixtures/js_frameworks/sveltekit/src/routes/+page.svelte new file mode 100644 index 00000000..a1cd455a --- /dev/null +++ b/tests/fixtures/js_frameworks/sveltekit/src/routes/+page.svelte @@ -0,0 +1,5 @@ + + SvelteKit Fixture + + +

SvelteKit Fixture

diff --git a/tests/fixtures/js_frameworks/sveltekit/svelte.config.js b/tests/fixtures/js_frameworks/sveltekit/svelte.config.js new file mode 100644 index 00000000..bc755511 --- /dev/null +++ b/tests/fixtures/js_frameworks/sveltekit/svelte.config.js @@ -0,0 +1,7 @@ +export default { + kit: { + adapter: { + name: 'auto' + } + } +}; diff --git a/tests/fixtures/js_frameworks/tanstack-start/app.config.ts b/tests/fixtures/js_frameworks/tanstack-start/app.config.ts new file mode 100644 index 00000000..1456308f --- /dev/null +++ b/tests/fixtures/js_frameworks/tanstack-start/app.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@tanstack/react-start/config'; + +export default defineConfig({ + ssr: true, +}); diff --git a/tests/fixtures/js_frameworks/tanstack-start/app/routes/index.tsx b/tests/fixtures/js_frameworks/tanstack-start/app/routes/index.tsx new file mode 100644 index 00000000..e43ec3f2 --- /dev/null +++ b/tests/fixtures/js_frameworks/tanstack-start/app/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/')({ + component: () =>
TanStack Start Fixture
, +}); diff --git a/tests/fixtures/js_frameworks/tanstack-start/package.json b/tests/fixtures/js_frameworks/tanstack-start/package.json new file mode 100644 index 00000000..ffca81d5 --- /dev/null +++ b/tests/fixtures/js_frameworks/tanstack-start/package.json @@ -0,0 +1,11 @@ +{ + "name": "fixture-tanstack-start", + "private": true, + "dependencies": { + "@tanstack/react-start": "1.0.0", + "@tanstack/react-router": "1.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "vinxi": "0.1.0" + } +} diff --git a/tests/framework_fixture_detection.rs b/tests/framework_fixture_detection.rs new file mode 100644 index 00000000..61bcc822 --- /dev/null +++ b/tests/framework_fixture_detection.rs @@ -0,0 +1,44 @@ +use std::path::Path; + +use syncable_cli::analyzer::analyze_project; + +struct Case<'a> { + name: &'a str, + path: &'a str, + expected_primary: &'a str, +} + +#[test] +fn detects_framework_across_10_fixtures() { + let cases = [ + Case { name: "nextjs", path: "tests/fixtures/js_frameworks/nextjs", expected_primary: "Next.js" }, + Case { name: "tanstack-start", path: "tests/fixtures/js_frameworks/tanstack-start", expected_primary: "Tanstack Start" }, + Case { name: "sveltekit", path: "tests/fixtures/js_frameworks/sveltekit", expected_primary: "SvelteKit" }, + Case { name: "nuxt", path: "tests/fixtures/js_frameworks/nuxt", expected_primary: "Nuxt.js" }, + Case { name: "astro", path: "tests/fixtures/js_frameworks/astro", expected_primary: "Astro" }, + Case { name: "solidstart", path: "tests/fixtures/js_frameworks/solidstart", expected_primary: "SolidStart" }, + Case { name: "react-router-spa", path: "tests/fixtures/js_frameworks/react-router-spa", expected_primary: "React Router v7" }, + Case { name: "angular", path: "tests/fixtures/js_frameworks/angular", expected_primary: "Angular" }, + Case { name: "expo", path: "tests/fixtures/js_frameworks/expo", expected_primary: "Expo" }, + Case { name: "express", path: "tests/fixtures/js_frameworks/express", expected_primary: "Express.js" }, + ]; + + for case in cases { + let analysis = analyze_project(Path::new(case.path)) + .unwrap_or_else(|e| panic!("{}: analysis failed: {}", case.name, e)); + + let mut found = None; + for tech in &analysis.technologies { + if tech.name == case.expected_primary { + found = Some(tech); + break; + } + } + + if let Some(primary) = found { + assert!(primary.is_primary, "{}: {} detected but not marked primary", case.name, case.expected_primary); + } else { + panic!("{}: expected to detect primary framework {} but did not. Detected: {:?}", case.name, case.expected_primary, analysis.technologies.iter().map(|t| t.name.clone()).collect::>()); + } + } +}