From eb1e21c059d83f0af9786e4d3cace80afe8456a2 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 23 Feb 2026 01:51:10 +0900 Subject: fix(validate): warn on orphan scripts-descriptions/aliases and honor config.lock - Detect scripts-descriptions and scripts-aliases keys referencing non-existent scripts and emit warnings matching Composer's behavior - Respect config.lock=false in composer.json to skip lock file checks unless --check-lock is explicitly passed Co-Authored-By: Claude Opus 4.6 --- crates/mozart/src/commands/self_update.rs | 7 +- crates/mozart/src/commands/validate.rs | 144 +++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/crates/mozart/src/commands/self_update.rs b/crates/mozart/src/commands/self_update.rs index 0c2f0f6..a0cd59d 100644 --- a/crates/mozart/src/commands/self_update.rs +++ b/crates/mozart/src/commands/self_update.rs @@ -460,9 +460,10 @@ fn clean_backups(data_dir: &Path, except: Option<&Path>) -> anyhow::Result<()> { if is_backup { if let Some(exc) = except - && path == exc { - continue; - } + && path == exc + { + continue; + } std::fs::remove_file(&path) .map_err(|e| anyhow::anyhow!("Could not remove backup {}: {e}", path.display()))?; } diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs index 614a1cc..8c6b6c3 100644 --- a/crates/mozart/src/commands/validate.rs +++ b/crates/mozart/src/commands/validate.rs @@ -66,6 +66,17 @@ impl ValidationResult { } } +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn should_check_lock(args: &ValidateArgs, manifest: &serde_json::Value) -> bool { + let config_lock_enabled = manifest + .get("config") + .and_then(|c| c.get("lock")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + (!args.no_check_lock && config_lock_enabled) || args.check_lock +} + // ─── Entry point ───────────────────────────────────────────────────────────── pub async fn execute( @@ -126,7 +137,7 @@ pub async fn execute( // Check lock file freshness let mut lock_errors: Vec = Vec::new(); - let check_lock = !args.no_check_lock || args.check_lock; + let check_lock = should_check_lock(args, &json_value); if check_lock { check_lock_freshness(&content, &file, &mut lock_errors); } @@ -201,6 +212,7 @@ fn validate_manifest( check_commit_references(obj, result); check_empty_psr_prefixes(obj, result); check_minimum_stability(obj, result); + check_scripts_orphans(obj, result); } // ─── Individual checks ─────────────────────────────────────────────────────── @@ -396,6 +408,38 @@ fn check_minimum_stability( } } +/// Warn about keys in scripts-descriptions or scripts-aliases that have no matching script. +fn check_scripts_orphans( + obj: &serde_json::Map, + result: &mut ValidationResult, +) { + let script_keys: std::collections::HashSet<&str> = obj + .get("scripts") + .and_then(|v| v.as_object()) + .map(|m| m.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + + if let Some(descriptions) = obj.get("scripts-descriptions").and_then(|v| v.as_object()) { + for key in descriptions.keys() { + if !script_keys.contains(key.as_str()) { + result.warnings.push(format!( + "Description for non-existent script \"{key}\" found in \"scripts-descriptions\"" + )); + } + } + } + + if let Some(aliases) = obj.get("scripts-aliases").and_then(|v| v.as_object()) { + for key in aliases.keys() { + if !script_keys.contains(key.as_str()) { + result.warnings.push(format!( + "Aliases for non-existent script \"{key}\" found in \"scripts-aliases\"" + )); + } + } + } +} + // ─── Dependency validation ─────────────────────────────────────────────── fn validate_dependencies( @@ -1107,6 +1151,104 @@ mod tests { assert!(lock_errors[0].contains("not up to date")); } + // ── check_scripts_orphans ────────────────────────────────────────────── + + #[test] + fn test_validate_scripts_descriptions_orphan_warns() { + let json = r#"{ + "name": "vendor/pkg", + "license": "MIT", + "scripts": {"build": "make build"}, + "scripts-descriptions": {"build": "Build the project", "nonexistent": "Ghost script"} + }"#; + let result = parse_and_validate(json, &make_args()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("nonexistent") && w.contains("scripts-descriptions")), + "expected orphan warning for scripts-descriptions, got: {:?}", + result.warnings + ); + assert!( + !result + .warnings + .iter() + .any(|w| w.contains("\"build\"") && w.contains("scripts-descriptions")), + "should not warn about existing script 'build'" + ); + } + + #[test] + fn test_validate_scripts_aliases_orphan_warns() { + let json = r#"{ + "name": "vendor/pkg", + "license": "MIT", + "scripts": {"build": "make build"}, + "scripts-aliases": {"build": ["b"], "ghost": ["g"]} + }"#; + let result = parse_and_validate(json, &make_args()); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("ghost") && w.contains("scripts-aliases")), + "expected orphan warning for scripts-aliases, got: {:?}", + result.warnings + ); + assert!( + !result + .warnings + .iter() + .any(|w| w.contains("\"build\"") && w.contains("scripts-aliases")), + "should not warn about existing script 'build'" + ); + } + + #[test] + fn test_validate_scripts_valid_no_orphan_warning() { + let json = r#"{ + "name": "vendor/pkg", + "license": "MIT", + "scripts": {"build": "make build", "test": "phpunit"}, + "scripts-descriptions": {"build": "Build the project", "test": "Run tests"}, + "scripts-aliases": {"build": ["b"], "test": ["t"]} + }"#; + let result = parse_and_validate(json, &make_args()); + assert!( + !result + .warnings + .iter() + .any(|w| w.contains("scripts-descriptions") || w.contains("scripts-aliases")), + "should produce no orphan warnings when all keys match, got: {:?}", + result.warnings + ); + } + + // ── should_check_lock ────────────────────────────────────────────────── + + #[test] + fn test_should_check_lock_config_false_disables() { + let args = make_args(); + let manifest = serde_json::json!({"config": {"lock": false}}); + assert!(!should_check_lock(&args, &manifest)); + } + + #[test] + fn test_should_check_lock_config_false_overridden_by_flag() { + let mut args = make_args(); + args.check_lock = true; + let manifest = serde_json::json!({"config": {"lock": false}}); + assert!(should_check_lock(&args, &manifest)); + } + + #[test] + fn test_should_check_lock_defaults_to_true() { + let args = make_args(); + let manifest = serde_json::json!({"name": "vendor/pkg"}); + assert!(should_check_lock(&args, &manifest)); + } + // ── Full manifest: valid package ─────────────────────────────────────── #[test] -- cgit v1.3.1