use crate::blueprint::{Pattern, 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(),
),
}),
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".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())
}
};
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 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)
}
}