use crate::blueprint::{Pattern, APP_SDK_MEMORY_REPORTING_FLOOR, CURRENT};
use anyhow::Result;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Serialize)]
struct Finding {
pattern: String,
severity: Severity,
message: String,
fix: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
enum Severity {
Ok,
Warning,
Error,
}
pub fn run(path: &Path, strict: bool, json: bool) -> Result<()> {
let mut findings = Vec::new();
let manifest_path = path.join("manifest.json");
let manifest = if manifest_path.exists() {
let raw = std::fs::read_to_string(&manifest_path)?;
Some(serde_json::from_str::<serde_json::Value>(&raw)?)
} else {
findings.push(Finding {
pattern: "manifest_present".into(),
severity: Severity::Error,
message: "manifest.json missing".into(),
fix: Some("run `node-app new` to scaffold, or hand-write a manifest.json".into()),
});
None
};
if let Some(m) = &manifest {
match m.get("app_type").and_then(|v| v.as_str()) {
Some("bun") => findings.push(Finding {
pattern: "app_type_check".into(),
severity: Severity::Ok,
message: "app_type=bun (eligible for shared runtime — set shared_runtime_enabled per-app at install time)".into(),
fix: None,
}),
Some("native") => findings.push(Finding {
pattern: "app_type_check".into(),
severity: Severity::Ok,
message: "app_type=native (cdylib, loaded in-process; shared runtime does not apply)".into(),
fix: None,
}),
Some("standalone") => findings.push(Finding {
pattern: "app_type_check".into(),
severity: Severity::Warning,
message: "app_type=standalone — own systemd unit; shared runtime memory savings do NOT apply".into(),
fix: Some(
"if you don't strictly need own-systemd-unit semantics, consider `app_type: bun` instead".into(),
),
}),
Some("managed-v1") => findings.push(Finding {
pattern: "app_type_check".into(),
severity: Severity::Ok,
message: "app_type=managed-v1 (LLMC-generated executable; host verification required)".into(),
fix: None,
}),
other => findings.push(Finding {
pattern: "app_type_check".into(),
severity: Severity::Error,
message: format!("manifest.app_type unrecognized: {:?}", other),
fix: Some("use one of: bun, native, standalone, managed-v1".into()),
}),
}
}
let pinned_min = pinned_min_blueprint(manifest.as_ref());
for pattern in CURRENT.patterns {
let f = match pattern {
Pattern::NoWholesaleNodeModulesWhenBundled => {
check_no_wholesale_node_modules(path, pinned_min)
}
Pattern::SharedExternalsMatchPin => {
check_shared_externals_match_pin(path, manifest.as_ref())
}
Pattern::PrivateNativeDepsDeclared => {
check_private_native_deps_declared(path, manifest.as_ref())
}
Pattern::NoBunBuildCompileForBunApps => {
check_no_bun_compile_for_bun_apps(path, manifest.as_ref())
}
Pattern::PrivateNativeDepsInPrivateModulesDir => {
check_private_native_deps_in_private_modules(path, manifest.as_ref())
}
Pattern::BunAppSdkReportsMemory => {
check_bun_app_sdk_reports_memory(path, manifest.as_ref())
}
Pattern::LazyAppCronCadenceFloor => {
check_lazy_app_cron_cadence_floor(path, manifest.as_ref())
}
Pattern::StageDeclaresDataContract => {
check_stage_declares_data_contract(path, manifest.as_ref())
}
Pattern::StageStreamListsAreDistinct => {
check_stage_stream_lists_are_distinct(path, manifest.as_ref())
}
};
if let Some(f) = f {
findings.push(f);
}
}
if json {
println!("{}", serde_json::to_string_pretty(&findings)?);
} else {
for f in &findings {
let icon = match f.severity {
Severity::Ok => "✓",
Severity::Warning => "⚠",
Severity::Error => "✗",
};
println!("{} {} — {}", icon, f.pattern, f.message);
if let Some(fix) = &f.fix {
println!(" fix: {}", fix);
}
}
}
let has_errors = findings.iter().any(|f| f.severity == Severity::Error);
let has_warnings = findings.iter().any(|f| f.severity == Severity::Warning);
if has_errors || (strict && has_warnings) {
std::process::exit(1);
}
Ok(())
}
fn check_no_wholesale_node_modules(path: &Path, pinned_min: u32) -> Option<Finding> {
let dist = path.join("dist/index.js");
let nm = path.join("node_modules");
if !(dist.exists() && nm.exists()) {
return None;
}
let severity = if pinned_min >= 2 {
Severity::Error
} else {
Severity::Warning
};
let message = if pinned_min >= 2 {
"dist/index.js present alongside `node_modules/`; blueprint v2 forbids staging wholesale node_modules/ in the .deb".to_string()
} else {
"dist/index.js present but `node_modules/` would be staged in the .deb (allowed under blueprint v1; tightens to error at v2)".to_string()
};
Some(Finding {
pattern: "NoWholesaleNodeModulesWhenBundled".into(),
severity,
message,
fix: Some(
"list native runtime deps under manifest.nodeApp.privateRuntime and pin manifest.nodeApp.blueprint: \">=2\" — `node-app package` then stages private_modules/<pkg> only".into(),
),
})
}
fn check_shared_externals_match_pin(
path: &Path,
_manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let pkg = path.join("package.json");
if !pkg.exists() {
return None;
}
Some(Finding {
pattern: "SharedExternalsMatchPin".into(),
severity: Severity::Ok,
message: "shared-deps version-pin checked via infra/scripts/lint-shared-deps.mjs (deferred to phase 6)".into(),
fix: None,
})
}
fn check_private_native_deps_declared(
path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let _ = (path, manifest);
Some(Finding {
pattern: "PrivateNativeDepsDeclared".into(),
severity: Severity::Ok,
message: "private-native-deps audit deferred to phase 4 — declare them manually in manifest.json#nodeApp.privateRuntime for now".into(),
fix: None,
})
}
fn check_no_bun_compile_for_bun_apps(
path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let app_type = manifest?.get("app_type")?.as_str()?;
if app_type != "bun" {
return None;
}
let pkg_path = path.join("package.json");
if !pkg_path.exists() {
return None;
}
let raw = std::fs::read_to_string(&pkg_path).ok()?;
let pkg: serde_json::Value = serde_json::from_str(&raw).ok()?;
let scripts = pkg.get("scripts")?.as_object()?;
for (name, cmd) in scripts {
if let Some(s) = cmd.as_str() {
if s.contains("bun build") && s.contains("--compile") {
return Some(Finding {
pattern: "NoBunBuildCompileForBunApps".into(),
severity: Severity::Warning,
message: format!(
"package.json script `{}` uses `bun build --compile` — incompatible with shared runtime",
name
),
fix: Some(
"drop --compile from the build script; ship dist/index.js so the supervisor can spawn it as a Worker".into(),
),
});
}
}
}
None
}
fn check_private_native_deps_in_private_modules(
path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let private_runtime = manifest
.and_then(|m| m.get("nodeApp"))
.and_then(|n| n.get("privateRuntime"))
.and_then(|v| v.as_array());
let pkgs: Vec<&str> = {
let arr = private_runtime?;
arr.iter().filter_map(|v| v.as_str()).collect()
};
if pkgs.is_empty() {
return None;
}
let dist = path.join("dist/index.js");
if !dist.exists() {
return Some(Finding {
pattern: "PrivateNativeDepsInPrivateModulesDir".into(),
severity: Severity::Ok,
message: format!(
"{} private runtime pkg(s) declared; run `node-app build` then re-audit to cross-check staging",
pkgs.len()
),
fix: None,
});
}
let mut missing: Vec<&str> = Vec::new();
for pkg in &pkgs {
let in_node_modules = path.join("node_modules").join(pkg).exists();
let in_private_modules = path.join("private_modules").join(pkg).exists();
if !in_node_modules && !in_private_modules {
missing.push(pkg);
}
}
if missing.is_empty() {
Some(Finding {
pattern: "PrivateNativeDepsInPrivateModulesDir".into(),
severity: Severity::Ok,
message: format!(
"all {} declared private runtime pkg(s) resolvable for staging: {}",
pkgs.len(),
pkgs.join(", ")
),
fix: None,
})
} else {
Some(Finding {
pattern: "PrivateNativeDepsInPrivateModulesDir".into(),
severity: Severity::Error,
message: format!(
"manifest.nodeApp.privateRuntime declares pkg(s) not on disk: {}",
missing.join(", ")
),
fix: Some(
"run `bun install` so node_modules/<pkg>/ exists; or remove the unused entries from manifest.nodeApp.privateRuntime".into(),
),
})
}
}
fn min_version_of_range(range: &str) -> Option<(u64, u64, u64)> {
let trimmed = range
.trim()
.trim_start_matches(['^', '~', '=', '>', '<', 'v'])
.trim();
let core = trimmed.split(['-', '+', ' ', ',']).next()?.trim();
let mut parts = core.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next().map_or(Some(0), |p| p.parse::<u64>().ok())?;
let patch = parts.next().map_or(Some(0), |p| p.parse::<u64>().ok())?;
Some((major, minor, patch))
}
fn check_bun_app_sdk_reports_memory(
path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
if manifest?.get("app_type")?.as_str()? != "bun" {
return None;
}
let pkg_path = path.join("package.json");
if !pkg_path.exists() {
return None;
}
let raw = std::fs::read_to_string(&pkg_path).ok()?;
let pkg: serde_json::Value = serde_json::from_str(&raw).ok()?;
let declared = pkg
.get("dependencies")
.and_then(|d| d.get("@econ-v1/app-sdk"))
.and_then(|v| v.as_str());
let (floor_major, floor_minor, floor_patch) = APP_SDK_MEMORY_REPORTING_FLOOR;
let floor = format!("{floor_major}.{floor_minor}.{floor_patch}");
let Some(range) = declared else {
return Some(Finding {
pattern: "BunAppSdkReportsMemory".into(),
severity: Severity::Ok,
message:
"app_type=bun but package.json declares no @econ-v1/app-sdk dependency — this app \
cannot self-report its JS heap, so the host will show it as unattributed"
.into(),
fix: Some(format!(
"add \"@econ-v1/app-sdk\": \"^{floor}\" to dependencies if this app runs on the SDK lifecycle"
)),
});
};
let Some(min) = min_version_of_range(range) else {
return Some(Finding {
pattern: "BunAppSdkReportsMemory".into(),
severity: Severity::Ok,
message: format!(
"could not determine the lowest @econ-v1/app-sdk version \"{range}\" resolves to; \
memory self-reporting needs >= {floor}"
),
fix: None,
});
};
if min >= APP_SDK_MEMORY_REPORTING_FLOOR {
Some(Finding {
pattern: "BunAppSdkReportsMemory".into(),
severity: Severity::Ok,
message: format!(
"@econ-v1/app-sdk \"{range}\" is at or above the {floor} memory-reporting floor — \
this app self-reports its heap (attribution: exact_isolate)"
),
fix: None,
})
} else {
Some(Finding {
pattern: "BunAppSdkReportsMemory".into(),
severity: Severity::Ok,
message: format!(
"@econ-v1/app-sdk \"{range}\" is BELOW the {floor} memory-reporting floor — the SDK \
never sends app_memory, so this app shows as unattributed in the per-app memory UI"
),
fix: Some(format!(
"bump the @econ-v1/app-sdk dependency to ^{floor} or newer (the 5.x -> 6.9.x jump is \
additive: no exports removed, engines unchanged)"
)),
})
}
}
fn pinned_min_blueprint(manifest: Option<&serde_json::Value>) -> u32 {
let s = manifest
.and_then(|m| m.get("nodeApp"))
.and_then(|n| n.get("blueprint"))
.and_then(|v| v.as_str())
.unwrap_or(">=1");
if let Some(rest) = s.strip_prefix(">=") {
rest.trim().parse::<u32>().unwrap_or(1)
} else {
s.trim().parse::<u32>().unwrap_or(1)
}
}
fn check_stage_declares_data_contract(
_path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let ui = manifest?.get("ui")?;
if ui.get("kind")?.as_str()? != "stage" {
return None;
}
if ui.get("data").is_some() {
return None;
}
Some(Finding {
pattern: "StageDeclaresDataContract".into(),
severity: Severity::Warning,
message: "ui.data is not declared, so this stage reports offline-ready with no data cached"
.into(),
fix: Some(
"add ui.data — use offline: \"online-only\" if the stage genuinely needs a connection"
.into(),
),
})
}
fn looks_like_app_data_stream_name(name: &str) -> bool {
if name.split('.').count() < 2 {
return false;
}
let Some(last) = name.split('.').next_back() else {
return false;
};
last.len() > 1 && last.starts_with('v') && last[1..].chars().all(|c| c.is_ascii_digit())
}
fn check_stage_stream_lists_are_distinct(
_path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let ui = manifest?.get("ui")?;
let requires: Vec<&str> = ui
.get("requires")
.and_then(|r| r.get("streams"))
.and_then(|s| s.as_array())
.map(|entries| entries.iter().filter_map(|e| e.as_str()).collect())
.unwrap_or_default();
let data: Vec<&str> = ui
.get("data")
.and_then(|d| d.get("streams"))
.and_then(|s| s.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|e| e.get("name").and_then(|n| n.as_str()))
.collect()
})
.unwrap_or_default();
if let Some(shared) = requires.iter().find(|name| data.contains(name)) {
return Some(Finding {
pattern: "StageStreamListsAreDistinct".into(),
severity: Severity::Error,
message: format!(
"'{shared}' is in both ui.requires.streams and ui.data.streams; they are different lists"
),
fix: Some(
"ui.requires.streams names shell/transport events; ui.data.streams names app-data sync streams — remove it from one".into(),
),
});
}
let stray = requires
.iter()
.find(|name| looks_like_app_data_stream_name(name))?;
Some(Finding {
pattern: "StageStreamListsAreDistinct".into(),
severity: Severity::Error,
message: format!(
"'{stray}' in ui.requires.streams has the ui.data.streams name shape and subscribes to nothing"
),
fix: Some(
"move it to ui.data.streams, or use an unversioned shell event name such as transport.state-changed".into(),
),
})
}
#[cfg(test)]
mod bun_sdk_memory_floor_tests {
use super::*;
fn write_app(dir: &std::path::Path, app_type: &str, sdk: Option<&str>) {
std::fs::write(
dir.join("manifest.json"),
format!(r#"{{"name":"t","version":"1.0.0","app_type":"{app_type}"}}"#),
)
.unwrap();
let deps = match sdk {
Some(v) => format!(r#"{{"@econ-v1/app-sdk":"{v}"}}"#),
None => "{}".to_string(),
};
std::fs::write(
dir.join("package.json"),
format!(r#"{{"name":"t","dependencies":{deps}}}"#),
)
.unwrap();
}
fn finding_for(app_type: &str, sdk: Option<&str>) -> Option<Finding> {
let tmp = tempfile::tempdir().unwrap();
write_app(tmp.path(), app_type, sdk);
let raw = std::fs::read_to_string(tmp.path().join("manifest.json")).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&raw).unwrap();
check_bun_app_sdk_reports_memory(tmp.path(), Some(&manifest))
}
#[test]
fn min_version_handles_the_range_shapes_these_manifests_actually_use() {
assert_eq!(min_version_of_range("6.9.4"), Some((6, 9, 4)));
assert_eq!(min_version_of_range("^6.9.4"), Some((6, 9, 4)));
assert_eq!(min_version_of_range("~6.9.0"), Some((6, 9, 0)));
assert_eq!(min_version_of_range(">=6.9.0"), Some((6, 9, 0)));
assert_eq!(min_version_of_range("=5.28.4"), Some((5, 28, 4)));
assert_eq!(min_version_of_range("^6"), Some((6, 0, 0)));
assert_eq!(min_version_of_range("^6.9"), Some((6, 9, 0)));
assert_eq!(min_version_of_range("6.9.0-rc.1"), Some((6, 9, 0)));
assert_eq!(min_version_of_range("latest"), None);
assert_eq!(min_version_of_range("workspace:*"), None);
}
#[test]
fn caret_five_x_is_below_the_floor_even_though_it_floats() {
let f = finding_for("bun", Some("^5.28.4")).expect("bun app yields a finding");
assert!(f.message.contains("BELOW"), "got: {}", f.message);
assert!(
f.fix.is_some(),
"a below-floor finding must say how to fix it"
);
}
#[test]
fn at_or_above_the_floor_passes() {
for range in ["^6.9.0", "6.9.4", "^7.0.0"] {
let f = finding_for("bun", Some(range)).expect("bun app yields a finding");
assert!(
f.message.contains("at or above"),
"{range} should pass, got: {}",
f.message
);
}
}
#[test]
fn reports_ok_severity_so_strict_releases_do_not_break() {
let f = finding_for("bun", Some("^5.28.4")).unwrap();
assert_eq!(f.severity, Severity::Ok);
}
#[test]
fn non_bun_apps_are_not_audited_for_this() {
assert!(finding_for("native", Some("^5.28.4")).is_none());
assert!(finding_for("standalone", None).is_none());
}
#[test]
fn missing_sdk_dependency_is_called_out_rather_than_silently_passing() {
let f = finding_for("bun", None).expect("a bun app with no SDK dep still yields a finding");
assert!(
f.message.contains("no @econ-v1/app-sdk"),
"got: {}",
f.message
);
}
}
#[cfg(test)]
mod stage_data_contract_tests {
use super::*;
#[test]
fn stage_without_ui_data_warns() {
let manifest = serde_json::json!({
"app_type": "bun",
"ui": { "kind": "stage", "entry": "ui/dist/main.js", "ui_api": 1 }
});
let finding =
check_stage_declares_data_contract(std::path::Path::new("."), Some(&manifest))
.expect("a stage with no ui.data must be reported");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn stage_with_explicit_online_only_is_silent() {
let manifest = serde_json::json!({
"app_type": "bun",
"ui": {
"kind": "stage", "entry": "ui/dist/main.js", "ui_api": 1,
"data": { "namespace": "notes", "offline": "online-only", "sync": "snapshot",
"queries": [{"name": "notes.snapshot.v1", "capability": "notes.snapshot", "kind": "snapshot"}],
"streams": [] }
}
});
assert!(
check_stage_declares_data_contract(std::path::Path::new("."), Some(&manifest))
.is_none()
);
}
#[test]
fn a_widget_is_not_a_stage_and_is_never_reported() {
let manifest = serde_json::json!({
"app_type": "bun",
"ui": { "kind": "widget", "entry": "ui/dist/main.js", "ui_api": 1 }
});
assert!(
check_stage_declares_data_contract(std::path::Path::new("."), Some(&manifest))
.is_none()
);
}
}
#[cfg(test)]
mod stage_stream_list_tests {
use super::*;
#[test]
fn a_name_in_both_stream_lists_is_an_error() {
let manifest = serde_json::json!({
"ui": { "kind": "stage",
"requires": { "streams": ["notes.changes.v1"] },
"data": { "namespace": "notes", "offline": "last-known", "sync": "cursor",
"queries": [{"name": "notes.snapshot.v1", "capability": "notes.snapshot", "kind": "snapshot"}],
"streams": [{"name": "notes.changes.v1", "kind": "changes"}] } }
});
let finding =
check_stage_stream_lists_are_distinct(std::path::Path::new("."), Some(&manifest))
.expect("a name in both lists must be reported");
assert_eq!(finding.severity, Severity::Error);
}
#[test]
fn a_namespaced_versioned_name_in_ui_requires_streams_is_an_error() {
let manifest = serde_json::json!({
"ui": { "kind": "stage", "requires": { "streams": ["notes.changes.v1"] } }
});
assert_eq!(
check_stage_stream_lists_are_distinct(std::path::Path::new("."), Some(&manifest))
.expect("a data-shaped name in ui.requires.streams must be reported")
.severity,
Severity::Error
);
}
#[test]
fn real_shell_event_subscriptions_are_silent() {
let manifest = serde_json::json!({
"ui": { "kind": "stage",
"requires": { "streams": ["transport.state-changed", "app.agent_session"] } }
});
assert!(
check_stage_stream_lists_are_distinct(std::path::Path::new("."), Some(&manifest))
.is_none()
);
}
}
const GOVERNOR_DEFAULT_MIN_IDLE_SECS: u64 = 600;
const GOVERNOR_SWEEP_CADENCE_SECS: u64 = 120;
const RUNTIME_CRITICAL_APPS: &[&str] = &["ldk-node", "cron", "message-queue", "observability"];
fn cron_min_period_secs(expr: &str) -> Option<u64> {
let f: Vec<&str> = expr.split_whitespace().collect();
if f.len() != 6 && f.len() != 7 {
return None;
}
let (sec, min, hour) = (f[0], f[1], f[2]);
fn list_min_gap(spec: &str) -> Option<u64> {
let mut vals: Vec<u64> = spec
.split(',')
.map(|p| p.trim().parse::<u64>().ok())
.collect::<Option<Vec<_>>>()?;
if vals.len() < 2 {
return None;
}
vals.sort_unstable();
vals.windows(2).map(|w| w[1] - w[0]).min()
}
fn step_of(spec: &str) -> Option<u64> {
let rest = spec.strip_prefix("*/")?;
rest.parse::<u64>().ok()
}
if sec == "*" {
return Some(1);
}
if let Some(n) = step_of(sec) {
return Some(n);
}
if let Some(g) = list_min_gap(sec) {
return Some(g);
}
if min == "*" {
return Some(60);
}
if let Some(n) = step_of(min) {
return Some(n * 60);
}
if let Some(g) = list_min_gap(min) {
return Some(g * 60);
}
if hour == "*" {
return Some(3600);
}
if let Some(n) = step_of(hour) {
return Some(n * 3600);
}
None
}
fn governor_may_stop(name: &str, manifest: &serde_json::Value) -> bool {
if RUNTIME_CRITICAL_APPS.contains(&name) {
return false;
}
if manifest.get("critical").and_then(|v| v.as_bool()) == Some(true) {
return false;
}
if manifest.get("app_type").and_then(|v| v.as_str()) == Some("standalone") {
return false;
}
if manifest
.get("governor")
.and_then(|g| g.get("terminable"))
.and_then(|v| v.as_bool())
== Some(false)
{
return false;
}
!matches!(
manifest.get("auto_start").and_then(|v| v.as_str()),
Some("auto") | Some("manual")
)
}
fn cron_literals(src: &str) -> Vec<String> {
let mut out = Vec::new();
for quote in ['"', '\'', '`'] {
for part in src.split(quote).skip(1).step_by(2) {
if part.len() <= 64 && cron_min_period_secs(part).is_some() {
out.push(part.to_string());
}
}
}
out
}
fn collect_sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if p.is_dir() {
if !matches!(name, "node_modules" | "dist" | ".git" | "target") {
collect_sources(&p, out);
}
} else if matches!(
p.extension().and_then(|x| x.to_str()),
Some("ts") | Some("tsx") | Some("js")
) && !name.contains(".test.")
{
out.push(p);
}
}
}
fn check_lazy_app_cron_cadence_floor(
path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<Finding> {
let manifest = manifest?;
let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("");
if !governor_may_stop(name, manifest) {
return None;
}
let min_idle = manifest
.get("governor")
.and_then(|g| g.get("min_idle_secs"))
.and_then(|v| v.as_u64())
.unwrap_or(GOVERNOR_DEFAULT_MIN_IDLE_SECS);
let floor = min_idle + GOVERNOR_SWEEP_CADENCE_SECS;
let mut sources = Vec::new();
collect_sources(&path.join("src"), &mut sources);
let mut worst: Option<(u64, String, String)> = None;
for file in sources {
let Ok(src) = std::fs::read_to_string(&file) else {
continue;
};
if !src.contains("core.cron.register") {
continue;
}
for lit in cron_literals(&src) {
let Some(period) = cron_min_period_secs(&lit) else {
continue;
};
if period <= floor && worst.as_ref().is_none_or(|(w, _, _)| period < *w) {
let rel = file
.strip_prefix(path)
.unwrap_or(&file)
.display()
.to_string();
worst = Some((period, lit, rel));
}
}
}
let (period, expr, file) = worst?;
Some(Finding {
pattern: "LazyAppCronCadenceFloor".into(),
severity: Severity::Warning,
message: format!(
"{file} registers a cron job every {period}s (\"{expr}\"), but the governor cannot \
idle-stop this app until it has been quiet for {min_idle}s and a sweep lands in that \
window (>{floor}s). This job re-wakes the app before that can ever happen, so its \
isolate stays resident for the life of the process and its memory is never reclaimed."
),
fix: Some(format!(
"slow the job to comfortably more than {floor}s, or — if it genuinely must run that \
often — declare the intent in manifest.json: a lower \"governor\": {{\"min_idle_secs\": \
...}} so the floor matches reality, or \"governor\": {{\"terminable\": false}} to opt \
out of idle termination altogether"
)),
})
}
#[cfg(test)]
mod cron_cadence_floor_tests {
use super::*;
#[test]
fn parses_the_shapes_this_platform_actually_uses() {
assert_eq!(cron_min_period_secs("*/15 * * * * *"), Some(15));
assert_eq!(cron_min_period_secs("*/30 * * * * *"), Some(30));
assert_eq!(cron_min_period_secs("0 * * * * *"), Some(60));
assert_eq!(cron_min_period_secs("0 */1 * * * *"), Some(60));
assert_eq!(cron_min_period_secs("0 */5 * * * *"), Some(300));
assert_eq!(cron_min_period_secs("0 */10 * * * *"), Some(600));
assert_eq!(cron_min_period_secs("0 */15 * * * *"), Some(900));
assert_eq!(cron_min_period_secs("0 0 * * * *"), Some(3600));
assert_eq!(cron_min_period_secs("0 15 * * * *"), Some(3600));
assert_eq!(cron_min_period_secs("0 0 */2 * * *"), Some(7200));
}
#[test]
fn coarse_and_unknown_shapes_are_not_findings() {
assert_eq!(cron_min_period_secs("0 0 3 * * *"), None); assert_eq!(cron_min_period_secs("0 0 3 * * 1"), None); assert_eq!(cron_min_period_secs("44 13 11 26 8 * 2026"), None); assert_eq!(cron_min_period_secs("not a cron"), None);
assert_eq!(cron_min_period_secs("* * * *"), None); }
#[test]
fn comma_lists_use_the_smallest_gap() {
assert_eq!(cron_min_period_secs("0,30 * * * * *"), Some(30));
assert_eq!(cron_min_period_secs("0 0,10,40 * * * *"), Some(600));
}
fn manifest(json: &str) -> serde_json::Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn runtime_critical_and_opted_out_apps_are_never_flagged() {
assert!(!governor_may_stop(
"ldk-node",
&manifest(r#"{"name":"ldk-node","auto_start":true}"#)
));
assert!(!governor_may_stop(
"x",
&manifest(r#"{"name":"x","critical":true}"#)
));
assert!(!governor_may_stop(
"x",
&manifest(r#"{"name":"x","app_type":"standalone"}"#)
));
assert!(!governor_may_stop(
"x",
&manifest(r#"{"name":"x","governor":{"terminable":false}}"#)
));
assert!(!governor_may_stop(
"x",
&manifest(r#"{"name":"x","auto_start":"auto"}"#)
));
}
#[test]
fn bool_and_absent_auto_start_count_as_lazy() {
assert!(governor_may_stop(
"economic",
&manifest(r#"{"name":"economic","auto_start":true}"#)
));
assert!(governor_may_stop("x", &manifest(r#"{"name":"x"}"#)));
assert!(governor_may_stop(
"onboarding",
&manifest(r#"{"name":"onboarding","auto_start":"lazy"}"#)
));
}
fn app_with(manifest_json: &str, src: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/index.ts"), src).unwrap();
std::fs::write(dir.path().join("manifest.json"), manifest_json).unwrap();
dir
}
#[test]
fn flags_a_job_faster_than_the_default_floor() {
let dir = app_with(
r#"{"name":"economic","app_type":"bun","auto_start":true}"#,
r#"await invokeCapability("core.cron.register", { schedule: "0 */5 * * * *" });"#,
);
let m = manifest(r#"{"name":"economic","app_type":"bun","auto_start":true}"#);
let f = check_lazy_app_cron_cadence_floor(dir.path(), Some(&m)).expect("expected a finding");
assert_eq!(f.severity, Severity::Warning);
assert!(f.message.contains("every 300s"), "{}", f.message);
assert!(f.message.contains("600s"), "{}", f.message);
}
#[test]
fn honours_a_per_app_min_idle_override() {
let mj = r#"{"name":"onboarding","app_type":"bun","auto_start":"lazy","governor":{"min_idle_secs":180}}"#;
let m = manifest(mj);
let bad = app_with(
mj,
r#"invokeCapability("core.cron.register", { schedule: "0 * * * * *" });"#,
);
let f = check_lazy_app_cron_cadence_floor(bad.path(), Some(&m)).expect("60s must flag");
assert!(f.message.contains("every 60s"), "{}", f.message);
let good = app_with(
mj,
r#"invokeCapability("core.cron.register", { schedule: "0 */15 * * * *" });"#,
);
assert!(check_lazy_app_cron_cadence_floor(good.path(), Some(&m)).is_none());
}
#[test]
fn ignores_cron_literals_in_files_that_never_register_a_job() {
let mj = r#"{"name":"x","app_type":"bun"}"#;
let m = manifest(mj);
let dir = app_with(mj, r#"const DOC_EXAMPLE = "0 */5 * * * *"; // not a registration"#);
assert!(check_lazy_app_cron_cadence_floor(dir.path(), Some(&m)).is_none());
}
#[test]
fn reports_the_fastest_offending_job_when_several_are_present() {
let mj = r#"{"name":"economic","app_type":"bun","auto_start":true}"#;
let m = manifest(mj);
let dir = app_with(
mj,
r#"invokeCapability("core.cron.register", [
{ schedule: "0 */10 * * * *" },
{ schedule: "0 */5 * * * *" },
{ schedule: "0 0 3 * * *" },
]);"#,
);
let f = check_lazy_app_cron_cadence_floor(dir.path(), Some(&m)).unwrap();
assert!(f.message.contains("every 300s"), "{}", f.message);
}
}