Skip to content

Commit 153ec3b

Browse files
Alex Holmbergclaude
authored andcommitted
feat(11.3-01): add health endpoint detection
Add HealthEndpoint and HealthEndpointSource types for deployment recommendations. Detect health endpoints from: - Framework conventions (Spring Actuator, Quarkus, etc.) - Source code patterns (route definitions) Includes health_endpoints field in ProjectAnalysis. Fix pre-existing test issue in types.rs. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7e0b13d commit 153ec3b

4 files changed

Lines changed: 461 additions & 0 deletions

File tree

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
//! Health endpoint detection for deployment recommendations.
2+
//!
3+
//! Detects health check endpoints by analyzing:
4+
//! - Source code patterns (route definitions)
5+
//! - Framework conventions (Spring Actuator, etc.)
6+
//! - Configuration files (K8s manifests)
7+
8+
use crate::analyzer::{DetectedTechnology, HealthEndpoint, HealthEndpointSource, TechnologyCategory};
9+
use crate::common::file_utils::{is_readable_file, read_file_safe};
10+
use crate::error::Result;
11+
use regex::Regex;
12+
use std::path::Path;
13+
14+
/// Common health check paths to scan for
15+
const COMMON_HEALTH_PATHS: &[&str] = &[
16+
"/health",
17+
"/healthz",
18+
"/ready",
19+
"/readyz",
20+
"/livez",
21+
"/live",
22+
"/api/health",
23+
"/api/v1/health",
24+
"/__health",
25+
"/ping",
26+
"/status",
27+
];
28+
29+
/// Detects health endpoints from project analysis
30+
pub fn detect_health_endpoints(
31+
project_root: &Path,
32+
technologies: &[DetectedTechnology],
33+
max_file_size: usize,
34+
) -> Vec<HealthEndpoint> {
35+
let mut endpoints = Vec::new();
36+
37+
// Check framework-specific defaults first
38+
for tech in technologies {
39+
if let Some(endpoint) = get_framework_health_endpoint(tech) {
40+
endpoints.push(endpoint);
41+
}
42+
}
43+
44+
// Scan source files for health route definitions
45+
let detected_from_code = scan_for_health_routes(project_root, technologies, max_file_size);
46+
for endpoint in detected_from_code {
47+
// Avoid duplicates - prefer code-detected over framework defaults
48+
if !endpoints.iter().any(|e| e.path == endpoint.path) {
49+
endpoints.push(endpoint);
50+
} else {
51+
// Upgrade existing endpoint if code detection has higher confidence
52+
if let Some(existing) = endpoints.iter_mut().find(|e| e.path == endpoint.path) {
53+
if endpoint.confidence > existing.confidence {
54+
*existing = endpoint;
55+
}
56+
}
57+
}
58+
}
59+
60+
// Sort by confidence (highest first)
61+
endpoints.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
62+
63+
endpoints
64+
}
65+
66+
/// Get framework-specific health endpoint defaults
67+
fn get_framework_health_endpoint(tech: &DetectedTechnology) -> Option<HealthEndpoint> {
68+
match tech.name.as_str() {
69+
// Java frameworks
70+
"Spring Boot" => Some(HealthEndpoint::from_framework("/actuator/health", "Spring Boot Actuator")),
71+
"Quarkus" => Some(HealthEndpoint::from_framework("/q/health", "Quarkus SmallRye Health")),
72+
"Micronaut" => Some(HealthEndpoint::from_framework("/health", "Micronaut")),
73+
74+
// Node.js frameworks - no standard, but common patterns
75+
"Express" | "Fastify" | "Koa" | "Hono" | "Elysia" | "NestJS" => {
76+
// Return a lower confidence endpoint since these don't have a standard
77+
Some(HealthEndpoint {
78+
path: "/health".to_string(),
79+
confidence: 0.5,
80+
source: HealthEndpointSource::FrameworkDefault,
81+
description: Some(format!("{} common health pattern", tech.name)),
82+
})
83+
}
84+
85+
// Python frameworks
86+
"FastAPI" => Some(HealthEndpoint::from_framework("/health", "FastAPI")),
87+
"Django" => Some(HealthEndpoint {
88+
path: "/health/".to_string(), // Django uses trailing slashes
89+
confidence: 0.5,
90+
source: HealthEndpointSource::FrameworkDefault,
91+
description: Some("Django common health pattern".to_string()),
92+
}),
93+
"Flask" => Some(HealthEndpoint {
94+
path: "/health".to_string(),
95+
confidence: 0.5,
96+
source: HealthEndpointSource::FrameworkDefault,
97+
description: Some("Flask common health pattern".to_string()),
98+
}),
99+
100+
// Go frameworks
101+
"Gin" | "Echo" | "Fiber" | "Chi" => Some(HealthEndpoint {
102+
path: "/health".to_string(),
103+
confidence: 0.5,
104+
source: HealthEndpointSource::FrameworkDefault,
105+
description: Some(format!("{} common health pattern", tech.name)),
106+
}),
107+
108+
// Rust frameworks
109+
"Actix Web" | "Axum" | "Rocket" => Some(HealthEndpoint {
110+
path: "/health".to_string(),
111+
confidence: 0.5,
112+
source: HealthEndpointSource::FrameworkDefault,
113+
description: Some(format!("{} common health pattern", tech.name)),
114+
}),
115+
116+
_ => None,
117+
}
118+
}
119+
120+
/// Scan source files for health route definitions
121+
fn scan_for_health_routes(
122+
project_root: &Path,
123+
technologies: &[DetectedTechnology],
124+
max_file_size: usize,
125+
) -> Vec<HealthEndpoint> {
126+
let mut endpoints = Vec::new();
127+
128+
// Determine which file types to scan based on detected technologies
129+
let has_js = technologies.iter().any(|t| {
130+
matches!(t.category, TechnologyCategory::BackendFramework | TechnologyCategory::MetaFramework)
131+
&& (t.name.contains("Express") || t.name.contains("Fastify") || t.name.contains("Koa")
132+
|| t.name.contains("Hono") || t.name.contains("Elysia") || t.name.contains("NestJS")
133+
|| t.name.contains("Next") || t.name.contains("Nuxt"))
134+
});
135+
136+
let has_python = technologies.iter().any(|t| {
137+
matches!(t.category, TechnologyCategory::BackendFramework)
138+
&& (t.name.contains("FastAPI") || t.name.contains("Flask") || t.name.contains("Django"))
139+
});
140+
141+
let has_go = technologies.iter().any(|t| {
142+
matches!(t.category, TechnologyCategory::BackendFramework)
143+
&& (t.name.contains("Gin") || t.name.contains("Echo") || t.name.contains("Fiber") || t.name.contains("Chi"))
144+
});
145+
146+
let has_rust = technologies.iter().any(|t| {
147+
matches!(t.category, TechnologyCategory::BackendFramework)
148+
&& (t.name.contains("Actix") || t.name.contains("Axum") || t.name.contains("Rocket"))
149+
});
150+
151+
let has_java = technologies.iter().any(|t| {
152+
matches!(t.category, TechnologyCategory::BackendFramework)
153+
&& (t.name.contains("Spring") || t.name.contains("Quarkus") || t.name.contains("Micronaut"))
154+
});
155+
156+
// Common locations to check
157+
let locations = [
158+
"src/",
159+
"app/",
160+
"routes/",
161+
"api/",
162+
"server/",
163+
"lib/",
164+
"handlers/",
165+
"controllers/",
166+
];
167+
168+
for location in &locations {
169+
let dir = project_root.join(location);
170+
if dir.is_dir() {
171+
if has_js {
172+
scan_directory_for_patterns(&dir, &["js", "ts", "mjs"], &js_health_patterns(), max_file_size, &mut endpoints);
173+
}
174+
if has_python {
175+
scan_directory_for_patterns(&dir, &["py"], &python_health_patterns(), max_file_size, &mut endpoints);
176+
}
177+
if has_go {
178+
scan_directory_for_patterns(&dir, &["go"], &go_health_patterns(), max_file_size, &mut endpoints);
179+
}
180+
if has_rust {
181+
scan_directory_for_patterns(&dir, &["rs"], &rust_health_patterns(), max_file_size, &mut endpoints);
182+
}
183+
if has_java {
184+
scan_directory_for_patterns(&dir, &["java", "kt"], &java_health_patterns(), max_file_size, &mut endpoints);
185+
}
186+
}
187+
}
188+
189+
// Also check root-level files
190+
if has_js {
191+
for entry in ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"] {
192+
let path = project_root.join(entry);
193+
if is_readable_file(&path) {
194+
scan_file_for_patterns(&path, &js_health_patterns(), max_file_size, &mut endpoints);
195+
}
196+
}
197+
}
198+
if has_python {
199+
for entry in ["main.py", "app.py", "wsgi.py", "asgi.py"] {
200+
let path = project_root.join(entry);
201+
if is_readable_file(&path) {
202+
scan_file_for_patterns(&path, &python_health_patterns(), max_file_size, &mut endpoints);
203+
}
204+
}
205+
}
206+
if has_go {
207+
let main_go = project_root.join("main.go");
208+
if is_readable_file(&main_go) {
209+
scan_file_for_patterns(&main_go, &go_health_patterns(), max_file_size, &mut endpoints);
210+
}
211+
}
212+
if has_rust {
213+
let main_rs = project_root.join("src/main.rs");
214+
if is_readable_file(&main_rs) {
215+
scan_file_for_patterns(&main_rs, &rust_health_patterns(), max_file_size, &mut endpoints);
216+
}
217+
}
218+
219+
endpoints
220+
}
221+
222+
/// Scan a directory for health route patterns
223+
fn scan_directory_for_patterns(
224+
dir: &Path,
225+
extensions: &[&str],
226+
patterns: &[(&str, f32)],
227+
max_file_size: usize,
228+
endpoints: &mut Vec<HealthEndpoint>,
229+
) {
230+
if let Ok(entries) = std::fs::read_dir(dir) {
231+
for entry in entries.flatten() {
232+
let path = entry.path();
233+
if path.is_file() {
234+
if let Some(ext) = path.extension() {
235+
if extensions.iter().any(|e| ext == *e) {
236+
scan_file_for_patterns(&path, patterns, max_file_size, endpoints);
237+
}
238+
}
239+
} else if path.is_dir() {
240+
// Skip common non-source directories
241+
let dir_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
242+
if !["node_modules", ".git", "target", "build", "dist", "__pycache__", ".next", "vendor"].contains(&dir_name.as_str()) {
243+
scan_directory_for_patterns(&path, extensions, patterns, max_file_size, endpoints);
244+
}
245+
}
246+
}
247+
}
248+
}
249+
250+
/// Scan a single file for health route patterns
251+
fn scan_file_for_patterns(
252+
path: &Path,
253+
patterns: &[(&str, f32)],
254+
max_file_size: usize,
255+
endpoints: &mut Vec<HealthEndpoint>,
256+
) {
257+
if let Ok(content) = read_file_safe(path, max_file_size) {
258+
for (pattern, confidence) in patterns {
259+
if let Ok(regex) = Regex::new(pattern) {
260+
for cap in regex.captures_iter(&content) {
261+
if let Some(path_match) = cap.get(1) {
262+
let health_path = path_match.as_str().to_string();
263+
// Only add if it looks like a health endpoint
264+
if COMMON_HEALTH_PATHS.iter().any(|p| health_path.contains(p) || p.contains(&health_path)) {
265+
if !endpoints.iter().any(|e| e.path == health_path) {
266+
endpoints.push(HealthEndpoint {
267+
path: health_path,
268+
confidence: *confidence,
269+
source: HealthEndpointSource::CodePattern,
270+
description: Some(format!("Found in {}", path.display())),
271+
});
272+
}
273+
}
274+
}
275+
}
276+
}
277+
}
278+
}
279+
}
280+
281+
/// JavaScript/TypeScript health route patterns
282+
fn js_health_patterns() -> Vec<(&'static str, f32)> {
283+
vec![
284+
// Express/Fastify/Koa style: app.get('/health', ...)
285+
(r#"\.(?:get|route)\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9),
286+
// NestJS style: @Get('health')
287+
(r#"@Get\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9),
288+
// Hono/Elysia style: .get('/health', ...)
289+
(r#"\.get\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9),
290+
]
291+
}
292+
293+
/// Python health route patterns
294+
fn python_health_patterns() -> Vec<(&'static str, f32)> {
295+
vec![
296+
// FastAPI/Flask style: @app.get("/health")
297+
(r#"@\w+\.(?:get|route)\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9),
298+
// Django URL patterns: path('health/', ...)
299+
(r#"path\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.85),
300+
]
301+
}
302+
303+
/// Go health route patterns
304+
fn go_health_patterns() -> Vec<(&'static str, f32)> {
305+
vec![
306+
// http.HandleFunc("/health", ...)
307+
(r#"HandleFunc\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9),
308+
// Gin/Echo: r.GET("/health", ...)
309+
(r#"\.(?:GET|Handle)\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9),
310+
]
311+
}
312+
313+
/// Rust health route patterns
314+
fn rust_health_patterns() -> Vec<(&'static str, f32)> {
315+
vec![
316+
// Actix: .route("/health", ...)
317+
(r#"\.route\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9),
318+
// Axum: .route("/health", get(...))
319+
(r#"\.route\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9),
320+
]
321+
}
322+
323+
/// Java health route patterns
324+
fn java_health_patterns() -> Vec<(&'static str, f32)> {
325+
vec![
326+
// Spring: @GetMapping("/health")
327+
(r#"@(?:Get|Request)Mapping\s*\(\s*(?:value\s*=\s*)?["']([^"']*(?:health|ready|live|status|ping)[^"']*)["']"#, 0.9),
328+
]
329+
}
330+
331+
#[cfg(test)]
332+
mod tests {
333+
use super::*;
334+
335+
#[test]
336+
fn test_spring_boot_health_endpoint() {
337+
let tech = DetectedTechnology {
338+
name: "Spring Boot".to_string(),
339+
version: None,
340+
category: TechnologyCategory::BackendFramework,
341+
confidence: 0.9,
342+
requires: vec![],
343+
conflicts_with: vec![],
344+
is_primary: true,
345+
file_indicators: vec![],
346+
};
347+
348+
let endpoint = get_framework_health_endpoint(&tech).unwrap();
349+
assert_eq!(endpoint.path, "/actuator/health");
350+
assert_eq!(endpoint.confidence, 0.7);
351+
}
352+
353+
#[test]
354+
fn test_express_health_endpoint() {
355+
let tech = DetectedTechnology {
356+
name: "Express".to_string(),
357+
version: None,
358+
category: TechnologyCategory::BackendFramework,
359+
confidence: 0.9,
360+
requires: vec![],
361+
conflicts_with: vec![],
362+
is_primary: true,
363+
file_indicators: vec![],
364+
};
365+
366+
let endpoint = get_framework_health_endpoint(&tech).unwrap();
367+
assert_eq!(endpoint.path, "/health");
368+
assert_eq!(endpoint.confidence, 0.5); // Lower confidence for non-standard
369+
}
370+
371+
#[test]
372+
fn test_unknown_framework_no_endpoint() {
373+
let tech = DetectedTechnology {
374+
name: "UnknownFramework".to_string(),
375+
version: None,
376+
category: TechnologyCategory::BackendFramework,
377+
confidence: 0.9,
378+
requires: vec![],
379+
conflicts_with: vec![],
380+
is_primary: true,
381+
file_indicators: vec![],
382+
};
383+
384+
assert!(get_framework_health_endpoint(&tech).is_none());
385+
}
386+
}

src/analyzer/context/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
pub mod analysis;
22
pub(crate) mod file_analyzers;
3+
pub(crate) mod health_detector;
34
pub(crate) mod helpers;
45
pub(crate) mod language_analyzers;
56
pub(crate) mod microservices;
67
pub(crate) mod project_type;
78
pub(crate) mod tech_specific;
89

910
pub use analysis::analyze_context;
11+
pub use health_detector::detect_health_endpoints;

0 commit comments

Comments
 (0)