Skip to content

Commit 381dae6

Browse files
Alex HolmbergAlex Holmberg
authored andcommitted
feat: updated framework detection
1 parent bdd43bf commit 381dae6

32 files changed

Lines changed: 337 additions & 29 deletions

src/analyzer/framework_detector.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,58 @@ mod tests {
119119
assert!(!react_tech.is_primary); // Should be false since Next.js is the meta-framework
120120
}
121121
}
122+
123+
#[test]
124+
fn test_vite_react_is_not_misclassified_as_next() {
125+
let language = DetectedLanguage {
126+
name: "TypeScript".to_string(),
127+
version: Some("18.0.0".to_string()),
128+
confidence: 0.9,
129+
files: vec![PathBuf::from("src/App.tsx")],
130+
main_dependencies: vec![
131+
"react".to_string(),
132+
"react-dom".to_string(),
133+
"vite".to_string(),
134+
],
135+
dev_dependencies: vec!["vite".to_string()],
136+
package_manager: Some("npm".to_string()),
137+
};
138+
139+
let config = AnalysisConfig::default();
140+
let project_root = Path::new(".");
141+
142+
let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
143+
144+
assert!(technologies.iter().any(|t| t.name == "Vite"));
145+
assert!(technologies.iter().any(|t| t.name == "React"));
146+
assert!(technologies.iter().all(|t| t.name != "Next.js"));
147+
}
148+
149+
#[test]
150+
fn test_tanstack_start_detection_over_structure_only() {
151+
let language = DetectedLanguage {
152+
name: "TypeScript".to_string(),
153+
version: Some("18.0.0".to_string()),
154+
confidence: 0.9,
155+
files: vec![PathBuf::from("app/routes/index.tsx")],
156+
main_dependencies: vec![
157+
"@tanstack/react-start".to_string(),
158+
"@tanstack/react-router".to_string(),
159+
"react".to_string(),
160+
"react-dom".to_string(),
161+
],
162+
dev_dependencies: vec![],
163+
package_manager: Some("npm".to_string()),
164+
};
165+
166+
let config = AnalysisConfig::default();
167+
let project_root = Path::new(".");
168+
169+
let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
170+
171+
assert!(technologies.iter().any(|t| t.name == "Tanstack Start"));
172+
assert!(technologies.iter().all(|t| t.name != "Next.js"));
173+
}
122174

123175
#[test]
124176
fn test_python_fastapi_detection() {
@@ -248,4 +300,4 @@ mod tests {
248300
assert!(async_runtimes.len() <= 1, "Should resolve conflicting async runtimes: found {:?}",
249301
async_runtimes.iter().map(|t| &t.name).collect::<Vec<_>>());
250302
}
251-
}
303+
}

src/analyzer/frameworks/javascript.rs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ fn detect_by_config_files(language: &DetectedLanguage, rules: &[TechnologyRule])
155155
}
156156
}
157157
// Check for Next.js config files
158-
else if file_name == "next.config.js" || file_name == "next.config.ts" {
158+
else if file_name.starts_with("next.config.") {
159159
if let Some(nextjs_rule) = rules.iter().find(|r| r.name == "Next.js") {
160160
detected.push(DetectedTechnology {
161161
name: nextjs_rule.name.clone(),
@@ -221,27 +221,29 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
221221
let mut has_encore_service_files = false;
222222
let mut has_app_json = false;
223223
let mut has_app_js_ts = false;
224-
224+
let mut has_next_config = false;
225+
let mut has_tanstack_config = false;
226+
225227
// Check project directories
226228
for file_path in &language.files {
227229
if let Some(parent) = file_path.parent() {
228230
let path_str = parent.to_string_lossy();
229231
let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
230-
232+
231233
// Check for React Native structure
232234
if path_str.contains("android") {
233235
has_android_dir = true;
234236
} else if path_str.contains("ios") {
235237
has_ios_dir = true;
236238
}
237239
// Check for Next.js structure
238-
else if path_str.contains("pages") {
240+
else if has_path_component(parent, "pages") {
239241
has_pages_dir = true;
240-
} else if path_str.contains("app") && !path_str.contains("app.config") && !path_str.contains("encore.app") {
242+
} else if has_path_component(parent, "app") && !file_name.contains("app.config") && !file_name.contains("encore.app") {
241243
has_app_dir = true;
242244
}
243245
// Check for TanStack Start structure
244-
else if path_str.contains("app/routes") {
246+
else if has_app_routes(parent) {
245247
has_app_routes_dir = true;
246248
}
247249
// Check for Encore structure
@@ -256,12 +258,22 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
256258
} else if file_name == "App.js" || file_name == "App.tsx" {
257259
has_app_js_ts = true;
258260
}
261+
262+
// Configs (need to be recorded so structure checks can require them)
263+
if file_name.starts_with("next.config.") {
264+
has_next_config = true;
265+
}
266+
if file_name == "app.config.ts" || file_name == "app.config.js" || file_name.starts_with("vinxi.config") {
267+
has_tanstack_config = true;
268+
}
259269
}
260270
}
261-
271+
262272
// Check if we have Expo dependencies
263273
let has_expo_deps = language.main_dependencies.iter().any(|dep| dep == "expo" || dep == "react-native");
264-
274+
let has_next_dep = language.main_dependencies.iter().any(|dep| dep == "next" || dep.starts_with("next@"));
275+
let has_tanstack_dep = language.main_dependencies.iter().any(|dep| dep.contains("tanstack/react-start") || dep.contains("tanstack-start") || dep.contains("vinxi"));
276+
265277
// Determine frameworks based on structure
266278
if has_encore_app_file || has_encore_service_files {
267279
// Likely Encore
@@ -277,7 +289,7 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
277289
file_indicators: encore_rule.file_indicators.clone(),
278290
});
279291
}
280-
} else if has_app_routes_dir {
292+
} else if has_app_routes_dir && (has_tanstack_dep || has_tanstack_config) {
281293
// Likely TanStack Start
282294
if let Some(tanstack_rule) = rules.iter().find(|r| r.name == "Tanstack Start") {
283295
detected.push(DetectedTechnology {
@@ -291,7 +303,7 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
291303
file_indicators: tanstack_rule.file_indicators.clone(),
292304
});
293305
}
294-
} else if has_pages_dir || has_app_dir {
306+
} else if (has_pages_dir || has_app_dir) && (has_next_dep || has_next_config) {
295307
// Likely Next.js
296308
if let Some(nextjs_rule) = rules.iter().find(|r| r.name == "Next.js") {
297309
detected.push(DetectedTechnology {
@@ -342,6 +354,21 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR
342354
}
343355
}
344356

357+
/// Returns true if any path component exactly matches the target (avoids substring false positives like "apps/")
358+
fn has_path_component(path: &Path, target: &str) -> bool {
359+
path.components()
360+
.any(|c| c.as_os_str().to_string_lossy() == target)
361+
}
362+
363+
/// Detects the canonical TanStack Start layout app/routes (component-level, not substring)
364+
fn has_app_routes(path: &Path) -> bool {
365+
let components: Vec<String> = path
366+
.components()
367+
.map(|c| c.as_os_str().to_string_lossy().to_string())
368+
.collect();
369+
components.windows(2).any(|w| w[0] == "app" && w[1] == "routes")
370+
}
371+
345372
/// New: Detect frameworks by analyzing source code patterns
346373
fn detect_by_source_patterns(language: &DetectedLanguage, rules: &[TechnologyRule]) -> Option<Vec<DetectedTechnology>> {
347374
let mut detected = Vec::new();
@@ -1287,4 +1314,4 @@ fn get_js_technology_rules() -> Vec<TechnologyRule> {
12871314
file_indicators: vec![],
12881315
},
12891316
]
1290-
}
1317+
}

src/analyzer/language_detector.rs

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -250,26 +250,40 @@ fn analyze_javascript_project(
250250
}
251251
}
252252

253-
// Extract dependencies
254-
if let Some(deps) = package_json.get("dependencies") {
255-
if let Some(deps_obj) = deps.as_object() {
256-
for (name, _) in deps_obj {
257-
info.main_dependencies.push(name.clone());
258-
}
253+
// Extract dependencies (always include all buckets for framework detection)
254+
if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) {
255+
for (name, _) in deps {
256+
info.main_dependencies.push(name.clone());
259257
}
260258
}
261-
262-
// Extract dev dependencies if enabled
263-
if config.include_dev_dependencies {
264-
if let Some(dev_deps) = package_json.get("devDependencies") {
265-
if let Some(dev_deps_obj) = dev_deps.as_object() {
266-
for (name, _) in dev_deps_obj {
267-
info.dev_dependencies.push(name.clone());
268-
}
269-
}
259+
260+
// Frameworks like Vite/Remix/Next are often in devDependencies; always include
261+
if let Some(dev_deps) = package_json.get("devDependencies").and_then(|d| d.as_object()) {
262+
for (name, _) in dev_deps {
263+
info.main_dependencies.push(name.clone());
264+
info.dev_dependencies.push(name.clone());
270265
}
271266
}
272-
267+
268+
// peerDependencies frequently carry framework identity (e.g., react-router)
269+
if let Some(peer_deps) = package_json.get("peerDependencies").and_then(|d| d.as_object()) {
270+
for (name, _) in peer_deps {
271+
info.main_dependencies.push(name.clone());
272+
}
273+
}
274+
275+
// optional/bundled deps can also hold framework markers (rare but cheap to add)
276+
if let Some(opt_deps) = package_json.get("optionalDependencies").and_then(|d| d.as_object()) {
277+
for (name, _) in opt_deps {
278+
info.main_dependencies.push(name.clone());
279+
}
280+
}
281+
if let Some(bundle_deps) = package_json.get("bundledDependencies").and_then(|d| d.as_array()) {
282+
for dep in bundle_deps.iter().filter_map(|d| d.as_str()) {
283+
info.main_dependencies.push(dep.to_string());
284+
}
285+
}
286+
273287
info.confidence = 0.95; // High confidence with manifest
274288
}
275289
}
@@ -1156,4 +1170,4 @@ black>=23.0.0
11561170
assert_eq!(languages[0].name, "Python");
11571171
assert!(languages[0].confidence > 0.8);
11581172
}
1159-
}
1173+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"version": 1,
3+
"projects": {
4+
"fixture-angular": {
5+
"root": "",
6+
"sourceRoot": "src",
7+
"projectType": "application"
8+
}
9+
}
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "fixture-angular",
3+
"private": true,
4+
"dependencies": {
5+
"@angular/core": "17.0.0"
6+
}
7+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { enableProdMode } from '@angular/core';
2+
3+
enableProdMode();
4+
console.log('Angular Fixture');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineConfig } from 'astro/config';
2+
3+
export default defineConfig({});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "fixture-astro",
3+
"private": true,
4+
"dependencies": {
5+
"astro": "4.0.0"
6+
}
7+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
// Astro fixture page
3+
---
4+
<h1>Astro Fixture</h1>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Text } from 'react-native';
2+
3+
export default function App() {
4+
return <Text>Expo Fixture</Text>;
5+
}

0 commit comments

Comments
 (0)