From aa6ead5a0166f7a14a308c84113d26c9bc6b748a Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Wed, 15 Apr 2026 19:20:38 -0700 Subject: [PATCH 1/4] robust health check detection (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `Healthcheck` enum (`Path`/`Command`) to replace `Option`, making the semantic distinction between HTTP paths and shell commands explicit in the type system - Fix Dockerfile `CMD-SHELL` parsing bug where `find("CMD")` matched inside `"CMD-SHELL"`, producing garbled output like `-SHELL curl ...` - Parse `[[http_service.checks]]` in fly.toml (was silently ignored) - Parse `healthcheck.path` in Heroku app.json (was silently ignored) - Add Phoenix health check inference (`/health` — ships in generated router since Phoenix 1.7) - Update all existing health check assertions to use typed enum variants Co-Authored-By: Claude Sonnet 4.6 --- src/signals/docker_compose.rs | 27 ++++++++++++----- src/signals/dockerfile.rs | 50 +++++++++++++++++++++++++------ src/signals/fly.rs | 48 ++++++++++++++++++++++++++++++ src/signals/heroku.rs | 48 ++++++++++++++++++++++++++++++ src/signals/package/elixir.rs | 13 +++++++-- src/signals/package/java.rs | 11 ++++--- src/signals/package/mod.rs | 55 +++++++++++++++++++++++++++++++---- src/signals/package/php.rs | 5 ++-- src/signals/package/ruby.rs | 5 ++-- src/signals/railway.rs | 6 ++-- src/types.rs | 13 +++++++-- tests/integration.rs | 2 +- 12 files changed, 243 insertions(+), 40 deletions(-) diff --git a/src/signals/docker_compose.rs b/src/signals/docker_compose.rs index 24d8bcd..4c6695e 100644 --- a/src/signals/docker_compose.rs +++ b/src/signals/docker_compose.rs @@ -203,7 +203,7 @@ fn parse_memory(s: &str) -> Option { .map(|n| (n * multiplier as f64).ceil() as u32) } -fn parse_healthcheck(hc: &HealthcheckConfig) -> Option { +fn parse_healthcheck(hc: &HealthcheckConfig) -> Option { let test = hc.test.as_ref()?; match test { StringOrArray::String(s) => { @@ -211,7 +211,7 @@ fn parse_healthcheck(hc: &HealthcheckConfig) -> Option { if s.eq_ignore_ascii_case("NONE") { None } else { - Some(s.to_string()) + Some(Healthcheck::Command(s.to_string())) } } StringOrArray::Array(arr) => { @@ -225,7 +225,11 @@ fn parse_healthcheck(hc: &HealthcheckConfig) -> Option { 0 }; let cmd = arr[start..].join(" "); - if cmd.is_empty() { None } else { Some(cmd) } + if cmd.is_empty() { + None + } else { + Some(Healthcheck::Command(cmd)) + } } } } @@ -923,8 +927,10 @@ services: "#, )]); assert_eq!( - services[0].healthcheck.as_deref(), - Some("curl -f http://localhost:3000/health") + services[0].healthcheck, + Some(Healthcheck::Command( + "curl -f http://localhost:3000/health".into() + )) ); } @@ -941,8 +947,10 @@ services: "#, )]); assert_eq!( - services[0].healthcheck.as_deref(), - Some("curl -f http://localhost:3000/health") + services[0].healthcheck, + Some(Healthcheck::Command( + "curl -f http://localhost:3000/health".into() + )) ); } @@ -958,7 +966,10 @@ services: test: ["CMD-SHELL", "pg_isready"] "#, )]); - assert_eq!(services[0].healthcheck.as_deref(), Some("pg_isready")); + assert_eq!( + services[0].healthcheck, + Some(Healthcheck::Command("pg_isready".into())) + ); } // -- Skip variants -- diff --git a/src/signals/dockerfile.rs b/src/signals/dockerfile.rs index c88f9dd..affdc74 100644 --- a/src/signals/dockerfile.rs +++ b/src/signals/dockerfile.rs @@ -133,12 +133,17 @@ fn parse_dockerfile(text: &str) -> ParsedDockerfile { let args = misc.arguments.to_string(); let args = args.trim(); if !args.eq_ignore_ascii_case("NONE") { - // Strip --interval/--timeout/etc. flags, keep CMD portion - if let Some(cmd_pos) = args.find("CMD") { - let cmd_part = args[cmd_pos + 3..].trim(); - if !cmd_part.is_empty() { - parsed.healthcheck = Some(cmd_part.to_string()); - } + // Strip --interval/--timeout/etc. flags, keep CMD portion. + // Check CMD-SHELL before CMD to avoid matching the substring. + let cmd_part = if let Some(pos) = args.find("CMD-SHELL") { + args[pos + 9..].trim() + } else if let Some(pos) = args.find("CMD") { + args[pos + 3..].trim() + } else { + "" + }; + if !cmd_part.is_empty() { + parsed.healthcheck = Some(cmd_part.to_string()); } } } @@ -384,7 +389,7 @@ impl Signal for DockerfileSignal { None }, exec_mode: Some(ExecMode::Daemon), - healthcheck: parsed.healthcheck, + healthcheck: parsed.healthcheck.map(Healthcheck::Command), volumes, detected_by: vec![dockerfile_path], ..Service::default() @@ -823,8 +828,10 @@ CMD node app.js"#, b"FROM node:20\nHEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:3000/health\nCMD node app.js", )]); assert_eq!( - services[0].healthcheck.as_deref(), - Some("curl -f http://localhost:3000/health") + services[0].healthcheck, + Some(Healthcheck::Command( + "curl -f http://localhost:3000/health".into() + )) ); } @@ -836,4 +843,29 @@ CMD node app.js"#, )]); assert!(services[0].healthcheck.is_none()); } + + #[test] + fn healthcheck_cmd_shell_with_flags() { + // Regression: CMD-SHELL must not be parsed as CMD + "-SHELL ..." + let services = run_signal(&[( + "Dockerfile", + b"FROM node:20\nHEALTHCHECK --interval=30s --timeout=5s CMD-SHELL curl -f http://localhost/health\nCMD node app.js", + )]); + assert_eq!( + services[0].healthcheck, + Some(Healthcheck::Command("curl -f http://localhost/health".into())) + ); + } + + #[test] + fn healthcheck_cmd_shell_no_flags() { + let services = run_signal(&[( + "Dockerfile", + b"FROM node:20\nHEALTHCHECK CMD-SHELL pg_isready\nCMD node app.js", + )]); + assert_eq!( + services[0].healthcheck, + Some(Healthcheck::Command("pg_isready".into())) + ); + } } diff --git a/src/signals/fly.rs b/src/signals/fly.rs index d7fe052..7400c39 100644 --- a/src/signals/fly.rs +++ b/src/signals/fly.rs @@ -31,6 +31,13 @@ struct FlyBuild { struct FlyHttpService { min_machines_running: Option, processes: Option>, + #[serde(default)] + checks: Vec, +} + +#[derive(Deserialize)] +struct FlyHttpCheck { + path: Option, } #[derive(Deserialize)] @@ -133,6 +140,14 @@ impl Signal for FlySignal { .and_then(|h| h.min_machines_running) .filter(|&r| r > 0); + // Health check path from http_service.checks + let healthcheck = config + .http_service + .as_ref() + .and_then(|h| h.checks.first()) + .and_then(|c| c.path.clone()) + .map(Healthcheck::Path); + // Dockerfile let dockerfile = config.build.as_ref().and_then(|b| b.dockerfile.clone()); @@ -188,6 +203,7 @@ impl Signal for FlySignal { dockerfile: dockerfile.clone(), resources: resources.clone(), replicas, + healthcheck: healthcheck.clone(), env: env.clone(), volumes, detected_by: vec!["fly".into()], @@ -218,6 +234,7 @@ impl Signal for FlySignal { dockerfile, resources, replicas, + healthcheck, env, volumes, detected_by: vec!["fly".into()], @@ -749,4 +766,35 @@ app = "my-api" assert_eq!(svcs.len(), 1); assert_eq!(svcs[0].name, "my-api"); } + + #[test] + fn http_service_healthcheck() { + let svcs = discover(&[( + "fly.toml", + br#" +app = "myapp" + +[http_service] + [[http_service.checks]] + path = "/health" + interval = "10s" + timeout = "2s" +"#, + )]); + assert_eq!(svcs[0].healthcheck, Some(Healthcheck::Path("/health".into()))); + } + + #[test] + fn http_service_no_healthcheck() { + let svcs = discover(&[( + "fly.toml", + br#" +app = "myapp" + +[http_service] + min_machines_running = 1 +"#, + )]); + assert!(svcs[0].healthcheck.is_none()); + } } diff --git a/src/signals/heroku.rs b/src/signals/heroku.rs index 6f832b4..e954302 100644 --- a/src/signals/heroku.rs +++ b/src/signals/heroku.rs @@ -165,6 +165,15 @@ impl Signal for HerokuSignal { } } + // Health check path → apply to services in this dir + if let Some(hc_path) = config.healthcheck.as_ref().and_then(|h| h.path.as_ref()) { + for svc in services.iter_mut().filter(|s| s.dir == dir_normalized) { + if svc.healthcheck.is_none() { + svc.healthcheck = Some(Healthcheck::Path(hc_path.clone())); + } + } + } + // Addons → separate backing services (databases, caches, etc.) for addon in &config.addons { let Some(raw_name) = extract_addon_name(addon) else { @@ -216,6 +225,12 @@ struct AppJson { formation: Option>, #[serde(default)] addons: Vec, + healthcheck: Option, +} + +#[derive(Deserialize)] +struct AppJsonHealthcheck { + path: Option, } #[derive(Deserialize)] @@ -541,4 +556,37 @@ mod tests { assert_eq!(disc.services.len(), 1); assert_eq!(disc.services[0].name, "web"); } + + #[test] + fn app_json_healthcheck() { + let fs = MemoryFs::new(&[ + ("Procfile", "web: npm start"), + ("package.json", r#"{"name":"app"}"#), + ( + "app.json", + r#"{"healthcheck":{"path":"/health"}}"#, + ), + ]); + + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + + let web = disc.services.iter().find(|s| s.name == "web").unwrap(); + assert_eq!(web.healthcheck, Some(Healthcheck::Path("/health".into()))); + } + + #[test] + fn app_json_no_healthcheck() { + let fs = MemoryFs::new(&[ + ("Procfile", "web: npm start"), + ("package.json", r#"{"name":"app"}"#), + ("app.json", r#"{"env":{"PORT":{"value":"3000"}}}"#), + ]); + + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + + let web = disc.services.iter().find(|s| s.name == "web").unwrap(); + assert!(web.healthcheck.is_none()); + } } diff --git a/src/signals/package/elixir.rs b/src/signals/package/elixir.rs index 5c94f1f..93f925c 100644 --- a/src/signals/package/elixir.rs +++ b/src/signals/package/elixir.rs @@ -12,8 +12,8 @@ use crate::signals::package::common::{ dir_string, parse_mise_toml, parse_tool_versions, read_text, strip_version_prefix, }; use crate::types::{ - Commands, DirContext, ElixirConfig, EnvVar, Language, LanguageConfig, PackageManagerInfo, - RuntimeInfo, + Commands, DirContext, ElixirConfig, EnvVar, Healthcheck, Language, LanguageConfig, + PackageManagerInfo, RuntimeInfo, }; /// Matches `elixir: "~> 1.17"` in mix.exs. @@ -125,6 +125,13 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt detected_by: vec!["mix.exs".into()], }]; + // -- Health check -- + // Phoenix 1.7+ ships GET /health in the generated router skeleton. + let healthcheck = match framework.as_deref() { + Some("phoenix") => Some(Healthcheck::Path("/health".into())), + _ => None, + }; + Some(DirContext { dir: dir_string(dir), language: Some(Language::Elixir), @@ -134,7 +141,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, - healthcheck: None, + healthcheck, env, system_deps, }) diff --git a/src/signals/package/java.rs b/src/signals/package/java.rs index 7b219bc..d73240b 100644 --- a/src/signals/package/java.rs +++ b/src/signals/package/java.rs @@ -13,7 +13,8 @@ use crate::signals::package::common::{ strip_version_prefix, }; use crate::types::{ - Commands, DirContext, JavaConfig, Language, LanguageConfig, PackageManagerInfo, RuntimeInfo, + Commands, DirContext, Healthcheck, JavaConfig, Language, LanguageConfig, PackageManagerInfo, + RuntimeInfo, }; /// Matches 21 in pom.xml. @@ -144,11 +145,13 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt // -- Health check -- let healthcheck = match framework.as_deref() { Some("spring-boot") | Some("grails") if content.contains("actuator") => { - Some("/actuator/health".into()) + Some(Healthcheck::Path("/actuator/health".into())) + } + Some("quarkus") if content.contains("smallrye-health") => { + Some(Healthcheck::Path("/q/health".into())) } - Some("quarkus") if content.contains("smallrye-health") => Some("/q/health".into()), Some("micronaut") if content.contains("micronaut-management") => { - Some("/health".into()) + Some(Healthcheck::Path("/health".into())) } _ => None, }; diff --git a/src/signals/package/mod.rs b/src/signals/package/mod.rs index 1968966..ca511eb 100644 --- a/src/signals/package/mod.rs +++ b/src/signals/package/mod.rs @@ -1092,8 +1092,8 @@ dependencies { implementation 'org.liquibase:liquibase-core' }"#, let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert_eq!( - disc.services[0].healthcheck.as_deref(), - Some("/actuator/health") + disc.services[0].healthcheck, + Some(Healthcheck::Path("/actuator/health".into())) ); } @@ -1121,7 +1121,10 @@ dependencies { implementation 'org.liquibase:liquibase-core' }"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/q/health")); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/q/health".into())) + ); } #[test] @@ -1133,7 +1136,10 @@ dependencies { implementation 'io.micronaut:micronaut-management' }"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/health")); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/health".into())) + ); } #[test] @@ -1144,7 +1150,10 @@ dependencies { implementation 'io.micronaut:micronaut-management' }"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/up")); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/up".into())) + ); } #[test] @@ -1172,7 +1181,41 @@ dependencies { implementation 'io.micronaut:micronaut-management' }"#, ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/up")); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/up".into())) + ); + } + + #[test] + fn elixir_phoenix_healthcheck() { + let fs = MemoryFs::new(&[( + "mix.exs", + r#"defmodule App.MixProject do + def project, do: [app: :app, elixir: "~> 1.17", deps: deps()] + defp deps, do: [{:phoenix, "~> 1.7"}] +end"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/health".into())) + ); + } + + #[test] + fn elixir_no_phoenix_no_healthcheck() { + let fs = MemoryFs::new(&[( + "mix.exs", + r#"defmodule App.MixProject do + def project, do: [app: :app, elixir: "~> 1.17", deps: deps()] + defp deps, do: [{:ecto_sql, "~> 3.11"}] +end"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services[0].healthcheck.is_none()); } // -- Elixir -- diff --git a/src/signals/package/php.rs b/src/signals/package/php.rs index f281bb8..9dd3b16 100644 --- a/src/signals/package/php.rs +++ b/src/signals/package/php.rs @@ -9,7 +9,8 @@ use crate::signals::package::common::{ read_text, strip_version_prefix, }; use crate::types::{ - Commands, DirContext, Language, LanguageConfig, PackageManagerInfo, PhpConfig, RuntimeInfo, + Commands, DirContext, Healthcheck, Language, LanguageConfig, PackageManagerInfo, PhpConfig, + RuntimeInfo, }; pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Option { @@ -183,7 +184,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt // -- Health check -- let healthcheck = match framework.as_deref() { - Some("laravel") => Some("/up".into()), + Some("laravel") => Some(Healthcheck::Path("/up".into())), _ => None, }; diff --git a/src/signals/package/ruby.rs b/src/signals/package/ruby.rs index 2e001ee..c0eb4f6 100644 --- a/src/signals/package/ruby.rs +++ b/src/signals/package/ruby.rs @@ -14,7 +14,8 @@ use crate::signals::package::common::{ strip_version_prefix, }; use crate::types::{ - Commands, DirContext, Language, LanguageConfig, PackageManagerInfo, RubyConfig, RuntimeInfo, + Commands, DirContext, Healthcheck, Language, LanguageConfig, PackageManagerInfo, RubyConfig, + RuntimeInfo, }; /// Matches `gem 'name'` or `gem "name"`. @@ -144,7 +145,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt // -- Health check -- let healthcheck = match framework.as_deref() { - Some("rails") => Some("/up".into()), + Some("rails") => Some(Healthcheck::Path("/up".into())), _ => None, }; diff --git a/src/signals/railway.rs b/src/signals/railway.rs index fbe59df..de9266f 100644 --- a/src/signals/railway.rs +++ b/src/signals/railway.rs @@ -130,7 +130,7 @@ fn map_config(config: RailwayConfig, dir: &Path) -> (Service, DirContext) { name, dir: dir_normalized.clone(), dockerfile: build.and_then(|b| b.dockerfile_path.clone()), - healthcheck: deploy.and_then(|d| d.healthcheck_path.clone()), + healthcheck: deploy.and_then(|d| d.healthcheck_path.clone()).map(Healthcheck::Path), replicas: deploy.and_then(|d| d.num_replicas), restart, schedule: cron_schedule.clone(), @@ -191,7 +191,7 @@ restartPolicyType = "ON_FAILURE" let svc = &disc.services[0]; assert_eq!(svc.start(), Some("npm start")); assert_eq!(svc.build(), Some("npm run build")); - assert_eq!(svc.healthcheck.as_deref(), Some("/health")); + assert_eq!(svc.healthcheck, Some(Healthcheck::Path("/health".into()))); assert_eq!(svc.replicas, Some(2)); assert_eq!(svc.restart, Some(Restart::OnFailure)); assert_eq!(svc.network, Some(Network::Public)); @@ -222,7 +222,7 @@ restartPolicyType = "ON_FAILURE" assert_eq!(disc.services.len(), 1); let svc = &disc.services[0]; assert_eq!(svc.start(), Some("node dist/index.js")); - assert_eq!(svc.healthcheck.as_deref(), Some("/health")); + assert_eq!(svc.healthcheck, Some(Healthcheck::Path("/health".into()))); assert_eq!(svc.replicas, Some(1)); assert_eq!(svc.restart, Some(Restart::Always)); } diff --git a/src/types.rs b/src/types.rs index 2c15293..eb3f189 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,6 +1,15 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Healthcheck { + /// An HTTP path to probe (e.g. "/health", "/up", "/actuator/health"). + Path(String), + /// A shell command to execute inside the container (e.g. "pg_isready", "curl -f ..."). + Command(String), +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Discovery { pub services: Vec, @@ -39,7 +48,7 @@ pub struct Service { #[serde(skip_serializing_if = "Option::is_none")] pub restart: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub healthcheck: Option, + pub healthcheck: Option, #[serde(skip_serializing_if = "Option::is_none")] pub schedule: Option, @@ -611,7 +620,7 @@ pub struct DirContext { pub output_dir: Option, pub commands: Commands, #[serde(skip_serializing_if = "Option::is_none")] - pub healthcheck: Option, + pub healthcheck: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub env: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/tests/integration.rs b/tests/integration.rs index e5c371e..b75af7e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -95,7 +95,7 @@ fn railway_dockerfile_package() { // fastify is a server library, not detected as framework assert!(svc.framework().is_none()); // Healthcheck from Railway - assert_eq!(svc.healthcheck.as_deref(), Some("/health")); + assert_eq!(svc.healthcheck, Some(Healthcheck::Path("/health".into()))); // NODE_ENV from Dockerfile context let node_env = svc.env.iter().find(|e| e.key == "NODE_ENV"); assert!(node_env.is_some()); From 10f3b71fd33114a243edf511eb25e7404c468e5a Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Wed, 15 Apr 2026 21:01:33 -0700 Subject: [PATCH 2/4] source-file health check detection (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HealthCheckSignal that detects health endpoints by finding a known health check library import AND a health path string in the same file. Both conditions must hold — co-occurrence in one file is near-certain evidence the endpoint exists at that path. Covers ~80% of real-world usage across Go, Node/TS, Python, and PHP: - Go: hellofresh/health-go, heptiolabs/healthcheck, alexliesenfeld/health, dimiro1/health, etherlabsio/healthcheck, InVisionApp/go-health, GlobalWebIndex/healthcheck, gofiber healthcheck middleware - Node/TS: @nestjs/terminus, @godaddy/terminus, express-healthcheck, healthcheck-middleware, fastify-healthcheck, lightship - Python: django-health-check (health_check.urls include pattern), py-healthcheck, fastapi-healthchecks, fastapi-health - PHP: pragmarx/health, liip/monitor-bundle, spatie/laravel-health Also adds Ruby gem auto-registration detection (no source scanning needed): - okcomputer → /okcomputer - health_check gem → /health_check Two-tier filtering (same pattern as LibraryCallsSignal): Aho-Corasick pre-filter eliminates non-matching files fast; regex confirms import and extracts the exact path string used at the registration site. Co-Authored-By: Claude Sonnet 4.6 --- src/signals/health_check.rs | 553 ++++++++++++++++++++++++++++++++++++ src/signals/mod.rs | 3 + src/signals/package/mod.rs | 287 +++++++++++-------- src/signals/package/ruby.rs | 12 +- 4 files changed, 742 insertions(+), 113 deletions(-) create mode 100644 src/signals/health_check.rs diff --git a/src/signals/health_check.rs b/src/signals/health_check.rs new file mode 100644 index 0000000..3df0874 --- /dev/null +++ b/src/signals/health_check.rs @@ -0,0 +1,553 @@ +// Static Regex/AhoCorasick patterns use compile-time string literals — construction is infallible. +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use aho_corasick::AhoCorasick; +use regex::bytes::Regex; + +use crate::error::LaunchError; +use crate::fs::{DirEntry, FileSystem}; +use crate::signal::{Signal, SignalOutput}; +use crate::types::{DirContext, Healthcheck}; + +const MAX_FILES_PER_DIR: usize = 30; + +// -- Language groups -- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum LangGroup { + Go, + JsTs, + Python, + Php, +} + +fn ext_to_lang_group(ext: &str) -> Option { + match ext { + "go" => Some(LangGroup::Go), + "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" => Some(LangGroup::JsTs), + "py" => Some(LangGroup::Python), + "php" => Some(LangGroup::Php), + _ => None, + } +} + +// -- Library import pre-filters (Aho-Corasick, one per language) -- +// These are broad: false positives just cost one extra regex pass. + +static HC_IMPORT_AC_GO: LazyLock = LazyLock::new(|| { + AhoCorasick::new([ + "hellofresh/health-go", + "heptiolabs/healthcheck", + "alexliesenfeld/health", + "dimiro1/health", + "etherlabsio/healthcheck", + "InVisionApp/go-health", + "GlobalWebIndex/healthcheck", + "gofiber/fiber/v2/middleware/healthcheck", + "gofiber/fiber/v3/middleware/healthcheck", + ]) + .unwrap() +}); + +static HC_IMPORT_AC_JS_TS: LazyLock = LazyLock::new(|| { + AhoCorasick::new([ + "@nestjs/terminus", + "@godaddy/terminus", + "express-healthcheck", + "healthcheck-middleware", + "fastify-healthcheck", + "lightship", + ]) + .unwrap() +}); + +static HC_IMPORT_AC_PYTHON: LazyLock = LazyLock::new(|| { + AhoCorasick::new([ + "health_check.urls", + "from healthcheck import", + "from fastapi_healthchecks", + "from fastapi_health import", + ]) + .unwrap() +}); + +static HC_IMPORT_AC_PHP: LazyLock = LazyLock::new(|| { + AhoCorasick::new([ + "PragmaRX\\Health", + "Liip\\Monitor", + "spatie/laravel-health", + ]) + .unwrap() +}); + +fn import_prefilter_for(lang: LangGroup) -> &'static AhoCorasick { + match lang { + LangGroup::Go => &HC_IMPORT_AC_GO, + LangGroup::JsTs => &HC_IMPORT_AC_JS_TS, + LangGroup::Python => &HC_IMPORT_AC_PYTHON, + LangGroup::Php => &HC_IMPORT_AC_PHP, + } +} + +// -- Health path pre-filter (shared across all languages) -- + +static HC_PATH_AC: LazyLock = LazyLock::new(|| { + AhoCorasick::new([ + "/health", // covers /health, /healthz, /healthcheck, /health_check + "health/", // Django-style: path("health/", include(...)) — no leading slash + "/ready", // covers /ready, /readyz, /readiness + "/live", // covers /live, /livez, /liveness + "/ping", + "/status", + "/readiness", + "/liveness", + ]) + .unwrap() +}); + +// -- Import regexes (per language, tighter than the pre-filter) -- + +static HC_GO_IMPORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r#""github\.com/(?:"#, + r#"hellofresh/health-go|"#, + r#"heptiolabs/healthcheck|"#, + r#"alexliesenfeld/health|"#, + r#"dimiro1/health|"#, + r#"etherlabsio/healthcheck|"#, + r#"InVisionApp/go-health|"#, + r#"GlobalWebIndex/healthcheck"#, + r#")[^"]*""#, + r#"|"github\.com/gofiber/fiber/v[23]/middleware/healthcheck""#, + )) + .unwrap() +}); + +static HC_JS_TS_IMPORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r#"['"](?:"#, + r#"@nestjs/terminus|"#, + r#"@godaddy/terminus|"#, + r#"express-healthcheck|"#, + r#"healthcheck-middleware|"#, + r#"fastify-healthcheck|"#, + r#"lightship"#, + r#")['"]"#, + )) + .unwrap() +}); + +static HC_PYTHON_IMPORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r#"health_check\.urls"#, + r#"|from\s+healthcheck\s+import"#, + r#"|from\s+fastapi_healthchecks\s+import"#, + r#"|from\s+fastapi_health\s+import"#, + )) + .unwrap() +}); + +static HC_PHP_IMPORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r#"use\s+(?:PragmaRX\\Health|Liip\\Monitor)"#, + r#"|spatie/laravel-health"#, + )) + .unwrap() +}); + +fn import_regex_for(lang: LangGroup) -> &'static Regex { + match lang { + LangGroup::Go => &HC_GO_IMPORT_RE, + LangGroup::JsTs => &HC_JS_TS_IMPORT_RE, + LangGroup::Python => &HC_PYTHON_IMPORT_RE, + LangGroup::Php => &HC_PHP_IMPORT_RE, + } +} + +// -- Path extraction regex (shared) -- +// Captures the path portion of a quoted health endpoint string literal. + +// Leading slash is optional: Django-style path("health/", ...) has no leading slash. +// The extracted value is normalized to start with "/" in generate(). +// Exclude "." from the character class to avoid matching module paths like "health_check.urls". +static HC_PATH_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"["'](/?(?:health[^"'.\s]*|ready[^"'.\s]*|live[^"'.\s]*|ping|status|readiness|liveness))["']"#, + ) + .unwrap() +}); + +// -- is_test_file / is_test_dir (same logic as LibraryCallsSignal) -- + +fn is_test_file(name: &str) -> bool { + let stem = match name.rfind('.') { + Some(i) => &name[..i], + None => return false, + }; + stem.ends_with(".test") + || stem.ends_with(".spec") + || stem.ends_with("_test") + || stem.ends_with("_spec") + || stem.starts_with("test_") + || stem.ends_with("Test") + || stem.ends_with("Tests") +} + +fn is_test_dir(dir: &Path) -> bool { + matches!( + dir.file_name().and_then(|n| n.to_str()), + Some( + "test" + | "tests" + | "spec" + | "specs" + | "__tests__" + | "__test__" + | "__mocks__" + | "fixtures" + | "testdata" + | "test_data" + ) + ) +} + +// -- Signal -- + +pub struct HealthCheckSignal { + dirs: HashMap>, +} + +impl HealthCheckSignal { + pub fn new() -> Self { + Self { + dirs: HashMap::new(), + } + } +} + +impl Signal for HealthCheckSignal { + fn name(&self) -> &'static str { + "health_check" + } + + fn observe(&mut self, dir: &Path, entry: &DirEntry) { + if entry.is_dir { + return; + } + let ext = match entry.path.extension().and_then(|e| e.to_str()) { + Some(e) => e, + None => return, + }; + let lang = match ext_to_lang_group(ext) { + Some(l) => l, + None => return, + }; + if is_test_file(&entry.name) || is_test_dir(dir) { + return; + } + if let Some(files) = self.dirs.get_mut(dir) { + if files.len() < MAX_FILES_PER_DIR { + files.push((entry.path.clone(), lang)); + } + } else { + self.dirs + .insert(dir.to_path_buf(), vec![(entry.path.clone(), lang)]); + } + } + + fn generate(&mut self, fs: &dyn FileSystem) -> Result { + use rayon::prelude::*; + + let dirs = std::mem::take(&mut self.dirs); + + let all_files: Vec<(PathBuf, PathBuf, LangGroup)> = dirs + .into_iter() + .flat_map(|(dir, files)| { + files + .into_iter() + .map(move |(path, lang)| (dir.clone(), path, lang)) + }) + .collect(); + + // Per-file: read, pre-filter (must pass both import and path filters), extract + let file_results: Vec<(PathBuf, Healthcheck)> = all_files + .into_par_iter() + .filter_map(|(dir, path, lang)| { + let bytes = match fs.read_file(&path) { + Ok(b) => b, + Err(_) => return None, + }; + + // Must contain a health library import substring + if !import_prefilter_for(lang).is_match(&bytes) { + return None; + } + // Must contain a health path substring + if !HC_PATH_AC.is_match(&bytes) { + return None; + } + // Confirm import via tighter regex + if !import_regex_for(lang).is_match(&bytes) { + return None; + } + // Extract the path string and normalize to start with "/" + let caps = HC_PATH_RE.captures(&bytes)?; + let path_match = caps.get(1)?; + let raw = std::str::from_utf8(path_match.as_bytes()).ok()?; + let health_path = if raw.starts_with('/') { + raw.to_string() + } else { + format!("/{raw}") + }; + + Some((dir, Healthcheck::Path(health_path))) + }) + .collect(); + + // Group by dir; first file wins per directory + let mut by_dir: HashMap = HashMap::new(); + for (dir, hc) in file_results { + by_dir.entry(dir).or_insert(hc); + } + + let context: Vec = by_dir + .into_iter() + .map(|(dir, healthcheck)| { + let dir_str = dir.to_string_lossy(); + let dir_normalized = if dir_str == "." || dir_str.is_empty() { + ".".to_string() + } else { + dir_str.into_owned() + }; + DirContext { + dir: dir_normalized, + healthcheck: Some(healthcheck), + ..DirContext::default() + } + }) + .collect(); + + Ok(SignalOutput { + context, + ..SignalOutput::default() + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::*; + use crate::fs::{DirEntry, MemoryFs}; + + fn run_signal(files: &[(&str, &[u8])]) -> Vec { + let fs = MemoryFs::from_bytes(files); + let mut signal = HealthCheckSignal::new(); + + for (path_str, _) in files { + let path = PathBuf::from(path_str); + let dir = path.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + let dir = if dir.as_os_str().is_empty() { + PathBuf::from(".") + } else { + dir + }; + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let entry = DirEntry { + path, + name, + is_dir: false, + }; + signal.observe(&dir, &entry); + } + + signal.generate(&fs).unwrap().context + } + + fn healthcheck_for(contexts: &[DirContext]) -> Option<&Healthcheck> { + contexts.iter().find_map(|c| c.healthcheck.as_ref()) + } + + // -- Go -- + + #[test] + fn go_health_go_with_path() { + let contexts = run_signal(&[( + "main.go", + br#" +import ( + health "github.com/hellofresh/health-go/v5" +) + +func main() { + h, _ := health.New() + http.Handle("/health", h.Handler()) +} +"#, + )]); + assert_eq!( + healthcheck_for(&contexts), + Some(&Healthcheck::Path("/health".into())) + ); + } + + #[test] + fn go_heptiolabs_with_healthz() { + let contexts = run_signal(&[( + "main.go", + br#" +import "github.com/heptiolabs/healthcheck" + +func main() { + health := healthcheck.NewHandler() + http.Handle("/healthz", health) +} +"#, + )]); + assert_eq!( + healthcheck_for(&contexts), + Some(&Healthcheck::Path("/healthz".into())) + ); + } + + #[test] + fn go_import_without_health_path() { + // Has the import but no health path string — no emission + let contexts = run_signal(&[( + "main.go", + br#" +import "github.com/hellofresh/health-go/v5" + +func main() { + http.ListenAndServe(":8080", nil) +} +"#, + )]); + assert!(healthcheck_for(&contexts).is_none()); + } + + #[test] + fn go_health_path_without_import() { + // Has a /health string but no health library import — no emission + let contexts = run_signal(&[( + "main.go", + br#" +import "net/http" + +func main() { + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) +} +"#, + )]); + assert!(healthcheck_for(&contexts).is_none()); + } + + #[test] + fn go_test_file_skipped() { + let contexts = run_signal(&[( + "main_test.go", + br#" +import "github.com/hellofresh/health-go/v5" +http.Handle("/health", h.Handler()) +"#, + )]); + assert!(healthcheck_for(&contexts).is_none()); + } + + // -- Node / TypeScript -- + + #[test] + fn node_nestjs_terminus_with_path() { + let contexts = run_signal(&[( + "health.controller.ts", + br#" +import { TerminusModule } from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor(private health: HealthCheckService) {} + + @Get('/health') + check() { return this.health.check([]); } +} +"#, + )]); + assert_eq!( + healthcheck_for(&contexts), + Some(&Healthcheck::Path("/health".into())) + ); + } + + #[test] + fn node_godaddy_terminus_with_path() { + let contexts = run_signal(&[( + "server.js", + br#" +const { createTerminus } = require('@godaddy/terminus'); + +createTerminus(server, { + healthChecks: { + '/healthcheck': onHealthCheck, + }, +}); +"#, + )]); + assert_eq!( + healthcheck_for(&contexts), + Some(&Healthcheck::Path("/healthcheck".into())) + ); + } + + #[test] + fn node_terminus_without_path() { + let contexts = run_signal(&[( + "server.js", + br#" +const { createTerminus } = require('@godaddy/terminus'); +createTerminus(server, { signal: 'SIGTERM' }); +"#, + )]); + assert!(healthcheck_for(&contexts).is_none()); + } + + // -- Python -- + + #[test] + fn python_django_health_check_urls() { + let contexts = run_signal(&[( + "urls.py", + br#" +from django.urls import path, include + +urlpatterns = [ + path("health/", include("health_check.urls")), +] +"#, + )]); + assert_eq!( + healthcheck_for(&contexts), + Some(&Healthcheck::Path("/health/".into())) + ); + } + + #[test] + fn python_import_without_path() { + let contexts = run_signal(&[( + "urls.py", + br#" +from django.urls import path, include +from health_check.urls import urlpatterns as health_urls +"#, + )]); + // No health path string present — no emission + assert!(healthcheck_for(&contexts).is_none()); + } +} diff --git a/src/signals/mod.rs b/src/signals/mod.rs index 3b4f730..ae83d7b 100644 --- a/src/signals/mod.rs +++ b/src/signals/mod.rs @@ -4,6 +4,7 @@ mod dockerfile; mod dotenv; mod fly; mod framework; +mod health_check; mod heroku; mod library_calls; mod monorepo; @@ -31,6 +32,8 @@ pub fn default_signals() -> Vec> { Box::new(structured_config::StructuredConfigSignal::new()), Box::new(package::PackageSignal::new()), Box::new(framework::FrameworkSignal::new()), + // health_check: source-file import+path co-occurrence detection + Box::new(health_check::HealthCheckSignal::new()), // library_calls is last — lowest priority for env vars Box::new(library_calls::LibraryCallsSignal::new()), // monorepo after all others — only emits monorepo info, not services or context diff --git a/src/signals/package/mod.rs b/src/signals/package/mod.rs index ca511eb..0851fa7 100644 --- a/src/signals/package/mod.rs +++ b/src/signals/package/mod.rs @@ -373,12 +373,12 @@ mod tests { ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "pnpm"); - assert_eq!( - disc.services[0].install(), - Some("pnpm install") - ); + assert_eq!(disc.services[0].install(), Some("pnpm install")); } #[test] @@ -389,7 +389,10 @@ mod tests { )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "pnpm"); assert_eq!(pm.version.as_deref(), Some("9.1.0")); if let Some(LanguageConfig::Node(ref nc)) = disc.services[0].runtimes[0].language_config { @@ -443,7 +446,10 @@ mod tests { ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "yarn"); } @@ -458,7 +464,10 @@ mod tests { ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "yarn-classic"); } @@ -540,10 +549,7 @@ start = "uvicorn main:app" assert_eq!(rt.version.as_deref(), Some("1.22.0")); assert_eq!(rt.version_source.as_deref(), Some("go.mod")); assert_eq!(svc.framework(), Some("gin")); - assert_eq!( - svc.build(), - Some("go build -ldflags=\"-w -s\" -o app .") - ); + assert_eq!(svc.build(), Some("go build -ldflags=\"-w -s\" -o app .")); assert_eq!(svc.start(), Some("./app")); } @@ -591,9 +597,7 @@ start = "uvicorn main:app" #[test] fn go_work_only_triggers_detection() { // go.work without go.mod should still detect Go - let fs = MemoryFs::new(&[ - ("go.work", "go 1.22.0\n\nuse (\n\t./svc1\n\t./svc2\n)"), - ]); + let fs = MemoryFs::new(&[("go.work", "go 1.22.0\n\nuse (\n\t./svc1\n\t./svc2\n)")]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert_eq!(disc.services.len(), 1); @@ -662,10 +666,7 @@ start = "uvicorn main:app" #[test] fn node_start_fallback_main_field() { - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","main":"src/server.js"}"#, - )]); + let fs = MemoryFs::new(&[("package.json", r#"{"name":"app","main":"src/server.js"}"#)]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let svc = &disc.services[0]; @@ -1144,9 +1145,50 @@ dependencies { implementation 'io.micronaut:micronaut-management' }"#, #[test] fn ruby_rails_healthcheck() { + let fs = MemoryFs::new(&[("Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/up".into())) + ); + } + + #[test] + fn ruby_okcomputer_healthcheck() { + let fs = MemoryFs::new(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'sinatra'\ngem 'okcomputer'\n"), + ("config.ru", "run Sinatra::Application"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/okcomputer".into())) + ); + } + + #[test] + fn ruby_health_check_gem_healthcheck() { + let fs = MemoryFs::new(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'sinatra'\ngem 'health_check'\n"), + ("config.ru", "run Sinatra::Application"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].healthcheck, + Some(Healthcheck::Path("/health_check".into())) + ); + } + + #[test] + fn ruby_rails_takes_precedence_over_health_check_gem() { + // Rails /up is set by the package signal (first-writer-wins). + // health_check gem is also present but doesn't override. let fs = MemoryFs::new(&[( "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\n", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'health_check'\n", )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); @@ -1159,10 +1201,7 @@ dependencies { implementation 'io.micronaut:micronaut-management' }"#, #[test] fn ruby_sinatra_no_healthcheck() { let fs = MemoryFs::new(&[ - ( - "Gemfile", - "source 'https://rubygems.org'\ngem 'sinatra'\n", - ), + ("Gemfile", "source 'https://rubygems.org'\ngem 'sinatra'\n"), ("config.ru", "run Sinatra::Application"), ]); let disc = @@ -1377,10 +1416,7 @@ end )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!( - disc.services[0].framework(), - Some("tanstack-start") - ); + assert_eq!(disc.services[0].framework(), Some("tanstack-start")); assert_eq!(disc.services[0].output_dir(), Some(".output")); } @@ -1695,12 +1731,12 @@ end ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "pdm"); - assert_eq!( - disc.services[0].install(), - Some("pdm install") - ); + assert_eq!(disc.services[0].install(), Some("pdm install")); } #[test] @@ -1911,10 +1947,7 @@ end } else { panic!("expected RustConfig"); } - assert_eq!( - disc.services[0].start(), - Some("./target/release/server") - ); + assert_eq!(disc.services[0].start(), Some("./target/release/server")); } #[test] @@ -1930,10 +1963,7 @@ end } else { panic!("expected RustConfig"); } - assert_eq!( - disc.services[0].start(), - Some("./target/release/cli") - ); + assert_eq!(disc.services[0].start(), Some("./target/release/cli")); } // ========================================================================== @@ -1957,7 +1987,10 @@ end let rt = &disc.services[0].runtimes[0]; assert_eq!(rt.version.as_deref(), Some("3.3.0")); assert_eq!(rt.version_source.as_deref(), Some("Gemfile.lock")); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.version.as_deref(), Some("2.5.4")); } @@ -2046,10 +2079,7 @@ end #[test] fn ruby_always_has_libyaml_jemalloc() { - let fs = MemoryFs::new(&[( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\n", - )]); + let fs = MemoryFs::new(&[("Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n")]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let svc = &disc.services[0]; @@ -2422,7 +2452,10 @@ end let svc = &disc.services[0]; assert_eq!(svc.language(), Some(Language::TypeScript)); assert_eq!(svc.runtimes[0].name, "deno"); - assert_eq!(svc.runtimes[0].package_manager.as_ref().unwrap().name, "deno"); + assert_eq!( + svc.runtimes[0].package_manager.as_ref().unwrap().name, + "deno" + ); assert_eq!(svc.start(), Some("deno task start")); } @@ -2471,10 +2504,7 @@ end .iter() .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) .unwrap(); - assert_eq!( - svc.runtimes[0].version.as_deref(), - Some("2.0.0") - ); + assert_eq!(svc.runtimes[0].version.as_deref(), Some("2.0.0")); assert_eq!( svc.runtimes[0].version_source.as_deref(), Some(".tool-versions") @@ -2495,10 +2525,7 @@ end .iter() .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) .unwrap(); - assert_eq!( - svc.runtimes[0].version.as_deref(), - Some("2.1.0") - ); + assert_eq!(svc.runtimes[0].version.as_deref(), Some("2.1.0")); assert_eq!( svc.runtimes[0].version_source.as_deref(), Some(".mise.toml") @@ -2516,10 +2543,7 @@ end crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert_eq!(disc.services.len(), 1); let svc = &disc.services[0]; - assert_eq!( - svc.start(), - Some("deno run --allow-all main.ts") - ); + assert_eq!(svc.start(), Some("deno run --allow-all main.ts")); assert_eq!(svc.build(), Some("deno cache main.ts")); } @@ -2804,7 +2828,10 @@ end crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; assert_eq!(rt.name, "node"); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + let pm = disc.services[0].runtimes[0] + .package_manager + .as_ref() + .unwrap(); assert_eq!(pm.name, "npm"); } @@ -2888,10 +2915,7 @@ end fn python_django_wsgi_app() { // Django project with WSGI_APPLICATION in settings.py let fs = MemoryFs::new(&[ - ( - "requirements.txt", - "django==5.0.0\ngunicorn==22.0.0\n", - ), + ("requirements.txt", "django==5.0.0\ngunicorn==22.0.0\n"), ("manage.py", "#!/usr/bin/env python"), ( "myapp/settings.py", @@ -2912,10 +2936,7 @@ end fn python_django_wsgi_config_dir() { // Django project with settings in config/ directory let fs = MemoryFs::new(&[ - ( - "requirements.txt", - "django==5.0.0\n", - ), + ("requirements.txt", "django==5.0.0\n"), ("manage.py", "#!/usr/bin/env python"), ( "config/settings.py", @@ -2997,7 +3018,11 @@ end ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"libpq-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"libpq-dev".to_string()) + ); } #[test] @@ -3012,19 +3037,30 @@ end ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"libmysqlclient-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"libmysqlclient-dev".to_string()) + ); } #[test] fn python_psycopg2_binary_no_sysdep() { // psycopg2-binary → no libpq-dev needed let fs = MemoryFs::new(&[ - ("requirements.txt", "django==5.0.0\npsycopg2-binary==2.9.9\n"), + ( + "requirements.txt", + "django==5.0.0\npsycopg2-binary==2.9.9\n", + ), ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(!disc.services[0].system_deps.contains(&"libpq-dev".to_string())); + assert!( + !disc.services[0] + .system_deps + .contains(&"libpq-dev".to_string()) + ); } #[test] @@ -3036,7 +3072,11 @@ end ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"libpq-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"libpq-dev".to_string()) + ); } #[test] @@ -3061,20 +3101,39 @@ end crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert!(disc.services[0].system_deps.contains(&"cmake".to_string())); assert!(disc.services[0].system_deps.contains(&"gcc".to_string())); - assert!(disc.services[0].system_deps.contains(&"python3-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"python3-dev".to_string()) + ); } #[test] fn python_scipy_system_deps() { let fs = MemoryFs::new(&[ - ("requirements.txt", "scipy==1.14.0\nnumpy==2.0.0\nflask==3.0.0\n"), + ( + "requirements.txt", + "scipy==1.14.0\nnumpy==2.0.0\nflask==3.0.0\n", + ), ("app.py", "import scipy"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"python3-dev".to_string())); - assert!(disc.services[0].system_deps.contains(&"gfortran".to_string())); - assert!(disc.services[0].system_deps.contains(&"libopenblas-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"python3-dev".to_string()) + ); + assert!( + disc.services[0] + .system_deps + .contains(&"gfortran".to_string()) + ); + assert!( + disc.services[0] + .system_deps + .contains(&"libopenblas-dev".to_string()) + ); } #[test] @@ -3085,14 +3144,25 @@ end ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"libssl-dev".to_string())); - assert!(disc.services[0].system_deps.contains(&"libffi-dev".to_string())); + assert!( + disc.services[0] + .system_deps + .contains(&"libssl-dev".to_string()) + ); + assert!( + disc.services[0] + .system_deps + .contains(&"libffi-dev".to_string()) + ); } #[test] fn python_celery_detection() { let fs = MemoryFs::new(&[ - ("requirements.txt", "django==5.0.0\ncelery==5.4.0\nredis==5.0.0\n"), + ( + "requirements.txt", + "django==5.0.0\ncelery==5.4.0\nredis==5.0.0\n", + ), ("manage.py", "#!/usr/bin/env python"), ]); let disc = @@ -3122,7 +3192,10 @@ end #[test] fn python_whitenoise_detection() { let fs = MemoryFs::new(&[ - ("requirements.txt", "django==5.0.0\nwhitenoise==6.7.0\ngunicorn==22.0.0\n"), + ( + "requirements.txt", + "django==5.0.0\nwhitenoise==6.7.0\ngunicorn==22.0.0\n", + ), ("manage.py", "#!/usr/bin/env python"), ]); let disc = @@ -3185,12 +3258,10 @@ end #[test] fn python_build_backend_setuptools() { - let fs = MemoryFs::new(&[ - ( - "pyproject.toml", - "[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", - ), - ]); + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { @@ -3202,12 +3273,10 @@ end #[test] fn python_build_backend_hatchling() { - let fs = MemoryFs::new(&[ - ( - "pyproject.toml", - "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"fastapi\", \"uvicorn\"]\n\n[project.scripts]\nstart = \"uvicorn main:app\"\n", - ), - ]); + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"fastapi\", \"uvicorn\"]\n\n[project.scripts]\nstart = \"uvicorn main:app\"\n", + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { @@ -3220,12 +3289,10 @@ end #[test] fn python_build_backend_maturin_adds_rustc() { // maturin build backend → needs rustc system dep - let fs = MemoryFs::new(&[ - ( - "pyproject.toml", - "[build-system]\nrequires = [\"maturin>=1.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", - ), - ]); + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"maturin>=1.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert!(disc.services[0].system_deps.contains(&"rustc".to_string())); @@ -3265,12 +3332,10 @@ end #[test] fn python_build_backend_flit() { - let fs = MemoryFs::new(&[ - ( - "pyproject.toml", - "[build-system]\nrequires = [\"flit_core>=3.2\"]\nbuild-backend = \"flit_core.buildapi\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", - ), - ]); + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"flit_core>=3.2\"]\nbuild-backend = \"flit_core.buildapi\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { @@ -3354,12 +3419,10 @@ end #[test] fn php_explicit_pdo_mysql_extension() { - let fs = MemoryFs::new(&[ - ( - "composer.json", - r#"{"require":{"php":">=8.2","slim/slim":"^4.0","ext-pdo_mysql":"*"}}"#, - ), - ]); + let fs = MemoryFs::new(&[( + "composer.json", + r#"{"require":{"php":">=8.2","slim/slim":"^4.0","ext-pdo_mysql":"*"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { diff --git a/src/signals/package/ruby.rs b/src/signals/package/ruby.rs index c0eb4f6..f087881 100644 --- a/src/signals/package/ruby.rs +++ b/src/signals/package/ruby.rs @@ -144,9 +144,19 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt }); // -- Health check -- + // Rails 7.1+ ships GET /up. okcomputer and health_check gems auto-register + // their routes on inclusion — no source-file scanning needed. let healthcheck = match framework.as_deref() { Some("rails") => Some(Healthcheck::Path("/up".into())), - _ => None, + _ => { + if dep_refs.iter().any(|d| *d == "okcomputer") { + Some(Healthcheck::Path("/okcomputer".into())) + } else if dep_refs.iter().any(|d| *d == "health_check") { + Some(Healthcheck::Path("/health_check".into())) + } else { + None + } + } }; Some(DirContext { From 928321561fa204c6dae304a932f4c76992db0478 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Wed, 15 Apr 2026 21:08:03 -0700 Subject: [PATCH 3/4] require leading slash in health path regex (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare words like `health`, `ping`, `status` could previously match without a leading `/`. The shared path regex now requires `/` explicitly. Django URL conf never uses leading slashes (`path("health/", include(...))`), so a dedicated HC_DJANGO_PATH_RE extracts the prefix from the `path()` call and prepends `/` — rather than making the shared regex optionally slash-less. Co-Authored-By: Claude Sonnet 4.6 --- src/discovery.rs | 16 +++++----- src/signals/dockerfile.rs | 11 +++---- src/signals/fly.rs | 5 +++- src/signals/framework.rs | 40 +++++-------------------- src/signals/health_check.rs | 45 +++++++++++++++++++--------- src/signals/heroku.rs | 10 +++---- src/signals/library_calls.rs | 8 ++--- src/signals/package/java.rs | 8 ++--- src/signals/package/node.rs | 50 +++++++++++++++++++------------- src/signals/package/php.rs | 7 ++--- src/signals/package/python.rs | 19 ++++-------- src/signals/railway.rs | 9 +++--- src/signals/structured_config.rs | 6 ++-- src/signals/vercel.rs | 16 ++++++---- src/types.rs | 32 +++++++------------- tests/integration.rs | 37 ++++++++++------------- 16 files changed, 151 insertions(+), 168 deletions(-) diff --git a/src/discovery.rs b/src/discovery.rs index 063f4db..12c0614 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -425,10 +425,7 @@ fn apply_contexts( /// Build merged context chain from root down to the given dir. /// Returns one DirContext per language group, with child values winning over parent. -fn build_ancestor_chain( - dir: &str, - contexts: &HashMap>, -) -> Vec { +fn build_ancestor_chain(dir: &str, contexts: &HashMap>) -> Vec { let normalized = normalize_dir(dir); let mut ancestor_dirs: Vec = Vec::new(); @@ -456,11 +453,14 @@ fn build_ancestor_chain( for dir_key in &ancestor_dirs { if let Some(dir_contexts) = contexts.get(dir_key) { for ctx in dir_contexts { - if let Some(existing) = result.iter_mut().find(|c| match (c.language, ctx.language) + if let Some(existing) = + result + .iter_mut() + .find(|c| match (c.language, ctx.language) { + (Some(a), Some(b)) => a.same_group(b), + (Some(_), None) | (None, Some(_)) | (None, None) => true, + }) { - (Some(a), Some(b)) => a.same_group(b), - (Some(_), None) | (None, Some(_)) | (None, None) => true, - }) { // Child overrides parent: child keeps its values, fills gaps from parent let mut child = ctx.clone(); child.merge(existing); diff --git a/src/signals/dockerfile.rs b/src/signals/dockerfile.rs index affdc74..b17e664 100644 --- a/src/signals/dockerfile.rs +++ b/src/signals/dockerfile.rs @@ -341,10 +341,9 @@ impl Signal for DockerfileSignal { let (runtimes, start_ctx) = if let Some(lang) = language { let rt = Runtime { language: lang, - name: runtime.as_ref().map_or_else( - || default_runtime_name(lang).into(), - |ri| ri.name.clone(), - ), + name: runtime + .as_ref() + .map_or_else(|| default_runtime_name(lang).into(), |ri| ri.name.clone()), version: runtime.as_ref().and_then(|ri| ri.version.clone()), version_source: runtime.as_ref().and_then(|ri| ri.source.clone()), package_manager: None, @@ -853,7 +852,9 @@ CMD node app.js"#, )]); assert_eq!( services[0].healthcheck, - Some(Healthcheck::Command("curl -f http://localhost/health".into())) + Some(Healthcheck::Command( + "curl -f http://localhost/health".into() + )) ); } diff --git a/src/signals/fly.rs b/src/signals/fly.rs index 7400c39..773eb95 100644 --- a/src/signals/fly.rs +++ b/src/signals/fly.rs @@ -781,7 +781,10 @@ app = "myapp" timeout = "2s" "#, )]); - assert_eq!(svcs[0].healthcheck, Some(Healthcheck::Path("/health".into()))); + assert_eq!( + svcs[0].healthcheck, + Some(Healthcheck::Path("/health".into())) + ); } #[test] diff --git a/src/signals/framework.rs b/src/signals/framework.rs index 45f39e9..7fbed27 100644 --- a/src/signals/framework.rs +++ b/src/signals/framework.rs @@ -221,10 +221,7 @@ mod tests { let svc = &disc.services[0]; assert_eq!(svc.framework(), Some("nuxt")); assert_eq!(svc.build(), Some("nuxt build")); - assert_eq!( - svc.start(), - Some("node .output/server/index.mjs") - ); + assert_eq!(svc.start(), Some("node .output/server/index.mjs")); assert_eq!(svc.dev(), Some("nuxt dev")); } @@ -280,14 +277,8 @@ mod tests { Some("python manage.py collectstatic --noinput") ); // Package's python module sets start and dev (registered before framework) - assert_eq!( - svc.start(), - Some("python manage.py runserver") - ); - assert_eq!( - svc.dev(), - Some("python manage.py runserver") - ); + assert_eq!(svc.start(), Some("python manage.py runserver")); + assert_eq!(svc.dev(), Some("python manage.py runserver")); } // -- Rails -- @@ -302,19 +293,13 @@ mod tests { assert_eq!(svc.framework(), Some("rails")); assert_eq!(svc.dir, "."); // Framework fills build (Package's ruby module only sets build when asset pipeline is present) - assert_eq!( - svc.build(), - Some("rails assets:precompile") - ); + assert_eq!(svc.build(), Some("rails assets:precompile")); // Package's ruby module sets start and dev (registered before framework) assert_eq!( svc.start(), Some("bundle exec bin/rails server -b 0.0.0.0 -p ${PORT:-3000} -e $RAILS_ENV") ); - assert_eq!( - svc.dev(), - Some("bundle exec bin/rails server") - ); + assert_eq!(svc.dev(), Some("bundle exec bin/rails server")); } #[test] @@ -345,10 +330,7 @@ mod tests { assert_eq!(svc.framework(), Some("laravel")); assert!(svc.build().is_none()); // Package's php module sets start with --host flag (registered before framework) - assert_eq!( - svc.start(), - Some("php artisan serve --host=0.0.0.0") - ); + assert_eq!(svc.start(), Some("php artisan serve --host=0.0.0.0")); assert_eq!(svc.dev(), Some("php artisan serve")); } @@ -513,10 +495,7 @@ mod tests { assert_eq!(svc.framework(), Some("symfony")); assert!(svc.build().is_none()); // Package's php module sets start (registered before framework) - assert_eq!( - svc.start(), - Some("php -S 0.0.0.0:8000 -t public") - ); + assert_eq!(svc.start(), Some("php -S 0.0.0.0:8000 -t public")); } #[test] @@ -579,10 +558,7 @@ mod tests { assert_eq!(svc.framework(), Some("pelican")); assert_eq!(svc.build(), Some("pelican content")); assert_eq!(svc.start(), Some("pelican --listen")); - assert_eq!( - svc.dev(), - Some("pelican --listen --autoreload") - ); + assert_eq!(svc.dev(), Some("pelican --listen --autoreload")); } #[test] diff --git a/src/signals/health_check.rs b/src/signals/health_check.rs index 3df0874..d1579cb 100644 --- a/src/signals/health_check.rs +++ b/src/signals/health_check.rs @@ -98,13 +98,14 @@ fn import_prefilter_for(lang: LangGroup) -> &'static AhoCorasick { static HC_PATH_AC: LazyLock = LazyLock::new(|| { AhoCorasick::new([ "/health", // covers /health, /healthz, /healthcheck, /health_check - "health/", // Django-style: path("health/", include(...)) — no leading slash "/ready", // covers /ready, /readyz, /readiness "/live", // covers /live, /livez, /liveness "/ping", "/status", "/readiness", "/liveness", + // Django URL conf uses no leading slash — handled by HC_DJANGO_PATH_RE separately + "health_check.urls", ]) .unwrap() }); @@ -168,19 +169,24 @@ fn import_regex_for(lang: LangGroup) -> &'static Regex { } } -// -- Path extraction regex (shared) -- -// Captures the path portion of a quoted health endpoint string literal. +// -- Path extraction regexes -- -// Leading slash is optional: Django-style path("health/", ...) has no leading slash. -// The extracted value is normalized to start with "/" in generate(). -// Exclude "." from the character class to avoid matching module paths like "health_check.urls". +// Shared regex: requires a leading "/" so bare words don't match. +// Exclude "." to avoid matching module paths like "health_check.urls". static HC_PATH_RE: LazyLock = LazyLock::new(|| { Regex::new( - r#"["'](/?(?:health[^"'.\s]*|ready[^"'.\s]*|live[^"'.\s]*|ping|status|readiness|liveness))["']"#, + r#"["'](/(?:health[^"'.\s]*|ready[^"'.\s]*|live[^"'.\s]*|ping|status|readiness|liveness))["']"#, ) .unwrap() }); +// Django-specific: path("prefix/", include("health_check.urls")) +// Django URL conf never uses leading slashes, so we capture the prefix and prepend one. +static HC_DJANGO_PATH_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"path\(\s*["']([^"']+)["']\s*,\s*include\(\s*["']health_check\.urls["']"#) + .unwrap() +}); + // -- is_test_file / is_test_dir (same logic as LibraryCallsSignal) -- fn is_test_file(name: &str) -> bool { @@ -294,14 +300,25 @@ impl Signal for HealthCheckSignal { if !import_regex_for(lang).is_match(&bytes) { return None; } - // Extract the path string and normalize to start with "/" - let caps = HC_PATH_RE.captures(&bytes)?; - let path_match = caps.get(1)?; - let raw = std::str::from_utf8(path_match.as_bytes()).ok()?; - let health_path = if raw.starts_with('/') { - raw.to_string() + // Extract the health path. + // Python/Django: use the dedicated path() call extractor and prepend "/". + // All other languages: use the shared regex which requires a leading "/". + let health_path = if lang == LangGroup::Python { + if let Some(caps) = HC_DJANGO_PATH_RE.captures(&bytes) { + let raw = + std::str::from_utf8(caps.get(1)?.as_bytes()).ok()?; + format!("/{raw}") + } else { + let caps = HC_PATH_RE.captures(&bytes)?; + std::str::from_utf8(caps.get(1)?.as_bytes()) + .ok()? + .to_string() + } } else { - format!("/{raw}") + let caps = HC_PATH_RE.captures(&bytes)?; + std::str::from_utf8(caps.get(1)?.as_bytes()) + .ok()? + .to_string() }; Some((dir, Healthcheck::Path(health_path))) diff --git a/src/signals/heroku.rs b/src/signals/heroku.rs index e954302..ec6289b 100644 --- a/src/signals/heroku.rs +++ b/src/signals/heroku.rs @@ -381,7 +381,10 @@ mod tests { #[test] fn procfile_in_subdirectory() { let fs = MemoryFs::new(&[ - ("apps/api/Procfile", "web: npm start\nworker: node worker.js"), + ( + "apps/api/Procfile", + "web: npm start\nworker: node worker.js", + ), ("apps/api/package.json", r#"{"name":"app"}"#), ]); @@ -562,10 +565,7 @@ mod tests { let fs = MemoryFs::new(&[ ("Procfile", "web: npm start"), ("package.json", r#"{"name":"app"}"#), - ( - "app.json", - r#"{"healthcheck":{"path":"/health"}}"#, - ), + ("app.json", r#"{"healthcheck":{"path":"/health"}}"#), ]); let disc = diff --git a/src/signals/library_calls.rs b/src/signals/library_calls.rs index de9b688..f50d855 100644 --- a/src/signals/library_calls.rs +++ b/src/signals/library_calls.rs @@ -191,13 +191,11 @@ static PHP_RE: LazyLock = LazyLock::new(|| { static JAVA_KOTLIN_RE: LazyLock = LazyLock::new(|| Regex::new(r#"System\.getenv\([ \t]*"([A-Z][A-Z0-9_]+)"[ \t]*\)"#).unwrap()); -static ELIXIR_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"System\.get_env\([ \t]*"([A-Z][A-Z0-9_]+)"[ \t]*\)"#).unwrap() -}); +static ELIXIR_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"System\.get_env\([ \t]*"([A-Z][A-Z0-9_]+)"[ \t]*\)"#).unwrap()); static DOTNET_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"Environment\.GetEnvironmentVariable\([ \t]*"([A-Z][A-Z0-9_]+)"[ \t]*\)"#) - .unwrap() + Regex::new(r#"Environment\.GetEnvironmentVariable\([ \t]*"([A-Z][A-Z0-9_]+)"[ \t]*\)"#).unwrap() }); fn regex_for(lang: LangGroup) -> &'static Regex { diff --git a/src/signals/package/java.rs b/src/signals/package/java.rs index d73240b..e45730b 100644 --- a/src/signals/package/java.rs +++ b/src/signals/package/java.rs @@ -33,9 +33,8 @@ static GRADLE_JAVA_RE: LazyLock = LazyLock::new(|| { }); /// Matches JavaLanguageVersion.of(21) in build.gradle.kts toolchain blocks. -static GRADLE_TOOLCHAIN_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"JavaLanguageVersion\.of\((\d+)\)"#).unwrap() -}); +static GRADLE_TOOLCHAIN_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"JavaLanguageVersion\.of\((\d+)\)"#).unwrap()); /// Matches 21 in pom.xml (maven-compiler-plugin 3.6+). static MAVEN_RELEASE_RE: LazyLock = @@ -60,8 +59,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt }; // -- Runtime version -- - let (version, source) = - detect_java_version(dir, files, build_content.as_deref(), is_maven, fs); + let (version, source) = detect_java_version(dir, files, build_content.as_deref(), is_maven, fs); let runtime = RuntimeInfo { name: "java".into(), diff --git a/src/signals/package/node.rs b/src/signals/package/node.rs index f32884b..41371bb 100644 --- a/src/signals/package/node.rs +++ b/src/signals/package/node.rs @@ -94,7 +94,11 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt } else { infer_node_start(runtime_name, &pkg, dir, fs) }; - let build = if has_script("build") { Some(run_script("build")) } else { None }; + let build = if has_script("build") { + Some(run_script("build")) + } else { + None + }; let dev = if has_script("dev") { Some(run_script("dev")) } else { @@ -113,17 +117,15 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let static_site = detect_static_site(dir, framework.as_deref(), &dep_refs, scripts, fs); // -- Output dir -- - let output_dir = framework - .as_deref() - .and_then(|fw| match fw { - "next" => Some(".next".to_string()), - "nuxt" | "tanstack-start" => Some(".output".to_string()), - "gatsby" => Some("public".to_string()), - "vite" | "astro" => Some("dist".to_string()), - "angular" => parse_angular_output_dir(dir, fs).or_else(|| Some("dist".to_string())), - "react" | "sveltekit" | "remix" | "react-router" => Some("build".to_string()), - _ => None, - }); + let output_dir = framework.as_deref().and_then(|fw| match fw { + "next" => Some(".next".to_string()), + "nuxt" | "tanstack-start" => Some(".output".to_string()), + "gatsby" => Some("public".to_string()), + "vite" | "astro" => Some("dist".to_string()), + "angular" => parse_angular_output_dir(dir, fs).or_else(|| Some("dist".to_string())), + "react" | "sveltekit" | "remix" | "react-router" => Some("build".to_string()), + _ => None, + }); // -- System deps -- let sys_patterns: &[(&str, &[&str])] = &[ @@ -158,7 +160,15 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt ), ("sharp", &["libvips-dev"]), ("prisma", &["openssl"]), - ("canvas", &["libcairo2-dev", "libjpeg-dev", "libpango1.0-dev", "libgif-dev"]), + ( + "canvas", + &[ + "libcairo2-dev", + "libjpeg-dev", + "libpango1.0-dev", + "libgif-dev", + ], + ), ( "playwright", &[ @@ -180,7 +190,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt "fonts-liberation", ], ), - ("@playwright/test", &[ + ( + "@playwright/test", + &[ "libnss3", "libatk1.0-0", "libatk-bridge2.0-0", @@ -197,7 +209,8 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt "libxext6", "libxfixes3", "fonts-liberation", - ]), + ], + ), ("bcrypt", &["python3", "make", "g++"]), ("argon2", &["python3", "make", "g++"]), ("better-sqlite3", &["python3", "make", "g++"]), @@ -208,8 +221,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let corepack = pm_field.is_some() && pm_name != "bun"; let has_puppeteer = dep_refs.contains(&"puppeteer"); - let has_playwright = - dep_refs.contains(&"playwright") || dep_refs.contains(&"@playwright/test"); + let has_playwright = dep_refs.contains(&"playwright") || dep_refs.contains(&"@playwright/test"); let has_prisma = dep_refs.contains(&"prisma"); let has_sharp = dep_refs.contains(&"sharp"); @@ -480,9 +492,7 @@ fn parse_angular_output_dir(dir: &Path, fs: &dyn FileSystem) -> Option { .and_then(|name| projects.get(name)); let project = default_project.or_else(|| projects.values().next())?; - let build = project - .get("architect") - .and_then(|a| a.get("build"))?; + let build = project.get("architect").and_then(|a| a.get("build"))?; let output_path = build .get("options") diff --git a/src/signals/package/php.rs b/src/signals/package/php.rs index 9dd3b16..dc20ccc 100644 --- a/src/signals/package/php.rs +++ b/src/signals/package/php.rs @@ -260,10 +260,9 @@ fn collect_dep_names(require: Option<&serde_json::Map>) -> Vec) -> (Option, Option) { match framework { Some("laravel") => (Some("php artisan serve --host=0.0.0.0".into()), None), - Some("symfony") | Some("slim") | Some("codeigniter") | Some("cakephp") => ( - Some("php -S 0.0.0.0:8000 -t public".into()), - None, - ), + Some("symfony") | Some("slim") | Some("codeigniter") | Some("cakephp") => { + (Some("php -S 0.0.0.0:8000 -t public".into()), None) + } _ => (None, None), } } diff --git a/src/signals/package/python.rs b/src/signals/package/python.rs index cbd37e1..cfb170e 100644 --- a/src/signals/package/python.rs +++ b/src/signals/package/python.rs @@ -5,8 +5,8 @@ use serde_json::Value; use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ - detect_framework, detect_system_deps, dir_string, extract_python_requires, - parse_mise_toml, parse_runtime_txt, parse_tool_versions, read_text, strip_version_prefix, + detect_framework, detect_system_deps, dir_string, extract_python_requires, parse_mise_toml, + parse_runtime_txt, parse_tool_versions, read_text, strip_version_prefix, }; use crate::types::{ Commands, DirContext, Language, LanguageConfig, PackageManagerInfo, PythonConfig, RuntimeInfo, @@ -471,9 +471,7 @@ fn infer_start_command( Some("django") if has_gunicorn => { // Use detected WSGI_APPLICATION if available for proper gunicorn binding match wsgi_app { - Some(app) => Some(format!( - "gunicorn --bind 0.0.0.0:${{PORT:-8000}} {app}" - )), + Some(app) => Some(format!("gunicorn --bind 0.0.0.0:${{PORT:-8000}} {app}")), None => Some("gunicorn".into()), } } @@ -580,10 +578,7 @@ fn extract_django_setting(content: &str, setting: &str) -> Option { let val = val .strip_prefix('"') .and_then(|v| v.strip_suffix('"')) - .or_else(|| { - val.strip_prefix('\'') - .and_then(|v| v.strip_suffix('\'')) - }); + .or_else(|| val.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))); if let Some(v) = val { if !v.is_empty() { return Some(v.to_string()); @@ -602,11 +597,7 @@ fn extract_django_setting(content: &str, setting: &str) -> Option { /// - poetry/pdm/uv already handle this (their `install` installs the project too). /// - pip needs `pip install .` instead of `pip install -r requirements.txt`. /// - pipenv needs `pipenv install` (handles pyproject.toml automatically). -fn infer_python_install( - pm: &str, - build_backend: Option<&str>, - files: &DirFiles, -) -> Option { +fn infer_python_install(pm: &str, build_backend: Option<&str>, files: &DirFiles) -> Option { match pm { "poetry" => Some("poetry install".into()), "uv" => Some("uv sync".into()), diff --git a/src/signals/railway.rs b/src/signals/railway.rs index de9266f..9547fbe 100644 --- a/src/signals/railway.rs +++ b/src/signals/railway.rs @@ -130,7 +130,9 @@ fn map_config(config: RailwayConfig, dir: &Path) -> (Service, DirContext) { name, dir: dir_normalized.clone(), dockerfile: build.and_then(|b| b.dockerfile_path.clone()), - healthcheck: deploy.and_then(|d| d.healthcheck_path.clone()).map(Healthcheck::Path), + healthcheck: deploy + .and_then(|d| d.healthcheck_path.clone()) + .map(Healthcheck::Path), replicas: deploy.and_then(|d| d.num_replicas), restart, schedule: cron_schedule.clone(), @@ -372,10 +374,7 @@ buildCommand = "npm run build:deploy" crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); // deploy.buildCommand wins over build.buildCommand - assert_eq!( - disc.services[0].build(), - Some("npm run build:deploy") - ); + assert_eq!(disc.services[0].build(), Some("npm run build:deploy")); } #[test] diff --git a/src/signals/structured_config.rs b/src/signals/structured_config.rs index bea2054..6a90828 100644 --- a/src/signals/structured_config.rs +++ b/src/signals/structured_config.rs @@ -86,8 +86,10 @@ fn pre_filter_for(lang: ConfigLang) -> &'static AhoCorasick { // Zod: KEY: z.something().default(value) static ZOD_DEFAULT_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"([A-Z][A-Z0-9_]+)[ \t]*:[ \t]*z\.[^,}]+\.default\([ \t]*["']?([^"')]+?)["']?[ \t]*\)"#) - .unwrap() + Regex::new( + r#"([A-Z][A-Z0-9_]+)[ \t]*:[ \t]*z\.[^,}]+\.default\([ \t]*["']?([^"')]+?)["']?[ \t]*\)"#, + ) + .unwrap() }); // Zod: KEY: z.something (no default) diff --git a/src/signals/vercel.rs b/src/signals/vercel.rs index 6a69a42..74caee9 100644 --- a/src/signals/vercel.rs +++ b/src/signals/vercel.rs @@ -163,7 +163,7 @@ mod tests { "installCommand": "pnpm install", "devCommand": "next dev" }"# - .as_slice(), + .as_slice(), ), ("package.json", b"{\"name\":\"app\"}"), ]); @@ -188,7 +188,10 @@ mod tests { #[test] fn output_directory() { let svcs = discover(&[ - ("vercel.json", br#"{ "outputDirectory": ".next" }"#.as_slice()), + ( + "vercel.json", + br#"{ "outputDirectory": ".next" }"#.as_slice(), + ), ("package.json", b"{\"name\":\"app\"}"), ]); @@ -206,7 +209,7 @@ mod tests { "NODE_ENV": "production" } }"# - .as_slice(), + .as_slice(), ), ("package.json", b"{\"name\":\"app\"}"), ]); @@ -230,7 +233,7 @@ mod tests { "PLAIN_VAR": "literal" } }"# - .as_slice(), + .as_slice(), ), ("package.json", b"{\"name\":\"app\"}"), ]); @@ -246,7 +249,10 @@ mod tests { #[test] fn subdirectory() { let svcs = discover(&[ - ("apps/web/vercel.json", br#"{ "framework": "nextjs" }"#.as_slice()), + ( + "apps/web/vercel.json", + br#"{ "framework": "nextjs" }"#.as_slice(), + ), ("apps/web/package.json", b"{\"name\":\"web\"}"), ]); diff --git a/src/types.rs b/src/types.rs index eb3f189..1e2f886 100644 --- a/src/types.rs +++ b/src/types.rs @@ -64,44 +64,32 @@ impl Service { /// Primary framework. pub fn framework(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.framework.as_deref()) + self.runtimes.iter().find_map(|r| r.framework.as_deref()) } /// First available start command across runtimes. pub fn start(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.start.as_deref()) + self.runtimes.iter().find_map(|r| r.start.as_deref()) } /// First available install command across runtimes. pub fn install(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.install.as_deref()) + self.runtimes.iter().find_map(|r| r.install.as_deref()) } /// First available build command across runtimes. pub fn build(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.build.as_deref()) + self.runtimes.iter().find_map(|r| r.build.as_deref()) } /// First available dev command across runtimes. pub fn dev(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.dev.as_deref()) + self.runtimes.iter().find_map(|r| r.dev.as_deref()) } /// Primary output directory. pub fn output_dir(&self) -> Option<&str> { - self.runtimes - .iter() - .find_map(|r| r.output_dir.as_deref()) + self.runtimes.iter().find_map(|r| r.output_dir.as_deref()) } /// Layer DirContexts onto this service, converting each to a Runtime. @@ -195,9 +183,11 @@ impl Runtime { /// Convert a DirContext into a Runtime. /// Returns None if the context has no language and no recognizable runtime name. pub fn from_context(ctx: &DirContext) -> Option { - let language = ctx - .language - .or_else(|| ctx.runtime.as_ref().and_then(|ri| language_from_runtime_name(&ri.name)))?; + let language = ctx.language.or_else(|| { + ctx.runtime + .as_ref() + .and_then(|ri| language_from_runtime_name(&ri.name)) + })?; let (name, version, version_source) = match &ctx.runtime { Some(ri) => (ri.name.clone(), ri.version.clone(), ri.source.clone()), None => (default_runtime_name(language).into(), None, None), diff --git a/tests/integration.rs b/tests/integration.rs index b75af7e..3e970e5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -133,18 +133,12 @@ fn multi_dockerfile() { assert_eq!(worker.language(), Some(Language::Python)); // api has FLASK_APP env and gunicorn start - assert_eq!( - api.start(), - Some("gunicorn app:create_app()") - ); + assert_eq!(api.start(), Some("gunicorn app:create_app()")); let flask_env = api.env.iter().find(|e| e.key == "FLASK_APP"); assert!(flask_env.is_some()); // worker has celery start - assert_eq!( - worker.start(), - Some("celery -A tasks worker") - ); + assert_eq!(worker.start(), Some("celery -A tasks worker")); // Both get env vars from .env context assert!(api.env.iter().any(|e| e.key == "DATABASE_URL")); @@ -412,10 +406,7 @@ fn go_service() { assert_eq!(svc.framework(), Some("echo")); // Go always gets default commands - assert_eq!( - svc.build(), - Some("go build -ldflags=\"-w -s\" -o app .") - ); + assert_eq!(svc.build(), Some("go build -ldflags=\"-w -s\" -o app .")); assert_eq!(svc.start(), Some("./app")); assert_eq!(svc.dev(), Some("go run .")); @@ -527,10 +518,7 @@ fn laravel_with_vue() { "composer.json", r#"{ "require": { "laravel/framework": "^10.0" } }"#, ), - ( - "package.json", - r#"{ "dependencies": { "vue": "3" } }"#, - ), + ("package.json", r#"{ "dependencies": { "vue": "3" } }"#), ]); assert_eq!(disc.services.len(), 1); @@ -567,7 +555,11 @@ fn pure_rails_single_runtime() { assert_eq!(disc.services.len(), 1); let svc = &disc.services[0]; - assert_eq!(svc.runtimes.len(), 1, "pure Rails should have exactly one runtime"); + assert_eq!( + svc.runtimes.len(), + 1, + "pure Rails should have exactly one runtime" + ); assert_eq!(svc.runtimes[0].language, Language::Ruby); assert_eq!(svc.runtimes[0].framework.as_deref(), Some("rails")); assert_eq!( @@ -588,7 +580,11 @@ fn pure_node_single_runtime() { assert_eq!(disc.services.len(), 1); let svc = &disc.services[0]; - assert_eq!(svc.runtimes.len(), 1, "pure Node should have exactly one runtime"); + assert_eq!( + svc.runtimes.len(), + 1, + "pure Node should have exactly one runtime" + ); assert_eq!(svc.runtimes[0].language, Language::JavaScript); assert_eq!(svc.start(), Some("npm run start")); assert_eq!(svc.runtimes[0].install.as_deref(), Some("npm install")); @@ -616,9 +612,6 @@ fn json_round_trip() { assert_eq!(parsed.services.len(), disc.services.len()); assert_eq!(parsed.services[0].name, disc.services[0].name); - assert_eq!( - parsed.services[0].start(), - disc.services[0].start() - ); + assert_eq!(parsed.services[0].start(), disc.services[0].start()); assert_eq!(parsed.services[0].env.len(), disc.services[0].env.len()); } From 577c584749a5311b1442d63120ea4b43a7e95b83 Mon Sep 17 00:00:00 2001 From: Jared Lunde Date: Wed, 15 Apr 2026 21:15:47 -0700 Subject: [PATCH 4/4] fix clippy warnings Co-Authored-By: Claude Sonnet 4.6 --- src/signals/health_check.rs | 2 +- src/signals/package/ruby.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/signals/health_check.rs b/src/signals/health_check.rs index d1579cb..2bc59f0 100644 --- a/src/signals/health_check.rs +++ b/src/signals/health_check.rs @@ -358,7 +358,7 @@ impl Signal for HealthCheckSignal { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { - use std::path::{Path, PathBuf}; + use std::path::PathBuf; use super::*; use crate::fs::{DirEntry, MemoryFs}; diff --git a/src/signals/package/ruby.rs b/src/signals/package/ruby.rs index f087881..bca7c2f 100644 --- a/src/signals/package/ruby.rs +++ b/src/signals/package/ruby.rs @@ -149,9 +149,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let healthcheck = match framework.as_deref() { Some("rails") => Some(Healthcheck::Path("/up".into())), _ => { - if dep_refs.iter().any(|d| *d == "okcomputer") { + if dep_refs.contains(&"okcomputer") { Some(Healthcheck::Path("/okcomputer".into())) - } else if dep_refs.iter().any(|d| *d == "health_check") { + } else if dep_refs.contains(&"health_check") { Some(Healthcheck::Path("/health_check".into())) } else { None