node-app-build 6.5.0

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app audit` — run the embedded blueprint's auditable patterns
//! against a project on disk. Soft mode (default) only warns; --strict
//! flips warnings to hard errors.
//!
//! Phase 1 of econ-v1/node#868 introduced the audit harness with three of
//! four patterns implemented (the other two stubbed with OK notes).
//! Phase 5a of #868 (this PR) tightens packaging:
//!   - `NoWholesaleNodeModulesWhenBundled` becomes an ERROR when the app
//!     pins `manifest.nodeApp.blueprint: ">=2"`. Apps pinned at v1 keep
//!     the warning for one release cycle.
//!   - `PrivateNativeDepsInPrivateModulesDir` (new) cross-checks that
//!     packages declared in `nodeApp.privateRuntime` are actually resolvable
//!     for staging under `private_modules/`.

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
    };

    // app_type — surface shared-runtime fitness up-front
    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()),
            }),
        }
    }

    // Resolve the app's declared blueprint pin (manifest.nodeApp.blueprint).
    // Defaults to ">=1" — apps that haven't opted into v2 yet keep the
    // v1 warning behavior for one release cycle.
    let pinned_min = pinned_min_blueprint(manifest.as_ref());

    // Implement each Pattern from CURRENT.patterns:
    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(())
}

/// Severity for `NoWholesaleNodeModulesWhenBundled` depends on the
/// blueprint version the app pins (manifest.nodeApp.blueprint):
///   - `>=1` (or unset): warning. Backward-compat for one release cycle.
///   - `>=2` (or higher): hard error. Apps that have opted into v2 MUST
///     keep the wholesale node_modules/ out of the .deb (use
///     `nodeApp.privateRuntime` to declare native deps instead).
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> {
    // Stub for phase 1 — defer the actual version-match logic to phase 6.
    // For now just print informational reminder that the shared-deps lint
    // (infra/scripts/lint-shared-deps.mjs) is the source of truth.
    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> {
    // Stub for phase 1 — checking which deps have native bindings is non-trivial
    // (need to walk node_modules and look for .node files / binding.gyp). Defer
    // the real check to phase 4. For now, an OK note.
    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
}

/// Cross-check that every package listed in `manifest.nodeApp.privateRuntime`
/// is resolvable at staging time — either present under `node_modules/<pkg>`
/// (so `node-app package` can copy it into `private_modules/<pkg>`) or
/// already pre-staged under `private_modules/<pkg>` in the source tree.
/// No-op when `nodeApp.privateRuntime` is empty/absent.
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() {
        // No staging artifact yet to cross-check against; this lint only
        // fires once `node-app build` has produced dist/.
        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(),
            ),
        })
    }
}

/// Parse `manifest.nodeApp.blueprint` (e.g. `">=2"`) and return the minimum
/// blueprint version it pins. Unrecognized / missing pin defaults to 1.
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");
    // Only `>=N` semantics are honored today; anything else falls back to v1.
    if let Some(rest) = s.strip_prefix(">=") {
        rest.trim().parse::<u32>().unwrap_or(1)
    } else {
        s.trim().parse::<u32>().unwrap_or(1)
    }
}