aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/validate.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 21:17:07 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 21:17:07 +0900
commitf20a342ecb96734418d0817f841ea14fd9a448e3 (patch)
treefc7c31942579684a13fdb5972216302b1d13eb3a /crates/mozart/src/commands/validate.rs
parentee17433f9beb95071f37aa6cfe659f14b81ce503 (diff)
downloadphp-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.tar.gz
php-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.tar.zst
php-mozart-f20a342ecb96734418d0817f841ea14fd9a448e3.zip
fix(diagnose): align with Composer's DiagnoseCommand orchestration
Restructures diagnose to mirror Composer's 17-step DiagnoseCommand: adds composer.json schema validation, custom composer-repo connectivity, COMPOSER_IPRESOLVE warning, and the checkConnectivityAndComposerNetworkHttpEnablement preflight; drops Mozart-only extras (cache-dir, lock freshness, trailing summary). Extracts the manifest validator into mozart-core::config_validator so both ValidateCommand and DiagnoseCommand depend on the shared module rather than each other -- the same shape Composer uses with Util\\ConfigValidator. Adds a thin HttpDownloader wrapper in mozart-core::http, shadowing Composer's Util\\HttpDownloader. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/validate.rs')
-rw-r--r--crates/mozart/src/commands/validate.rs1135
1 files changed, 6 insertions, 1129 deletions
diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs
index 44e3f5e..77aecaa 100644
--- a/crates/mozart/src/commands/validate.rs
+++ b/crates/mozart/src/commands/validate.rs
@@ -1,15 +1,8 @@
use clap::Args;
+use mozart_core::config_validator::{ValidationResult, ValidatorOptions, validate_manifest};
use mozart_core::console_format;
use mozart_core::console_writeln;
-use regex::Regex;
use std::path::{Path, PathBuf};
-use std::sync::LazyLock;
-
-static DEPRECATED_GPL_OR_LATER_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?\+$").unwrap());
-
-static DEPRECATED_GPL_BARE_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?$").unwrap());
#[derive(Args)]
pub struct ValidateArgs {
@@ -45,31 +38,9 @@ pub struct ValidateArgs {
pub strict: bool,
}
-struct ValidationResult {
- errors: Vec<String>,
- publish_errors: Vec<String>,
- warnings: Vec<String>,
-}
-
-impl ValidationResult {
- fn new() -> Self {
- Self {
- errors: Vec::new(),
- publish_errors: Vec::new(),
- warnings: Vec::new(),
- }
- }
-
- fn has_errors(&self) -> bool {
- !self.errors.is_empty()
- }
-
- fn has_publish_errors(&self) -> bool {
- !self.publish_errors.is_empty()
- }
-
- fn has_warnings(&self) -> bool {
- !self.warnings.is_empty()
+fn options_from_args(args: &ValidateArgs) -> ValidatorOptions {
+ ValidatorOptions {
+ check_version: !args.no_check_version,
}
}
@@ -132,8 +103,7 @@ pub async fn execute(
};
// Run manifest validations
- let mut result = ValidationResult::new();
- validate_manifest(&json_value, args, &mut result);
+ let result = validate_manifest(&json_value, &options_from_args(args));
// Check lock file freshness
let mut lock_errors: Vec<String> = Vec::new();
@@ -189,454 +159,6 @@ pub async fn execute(
Ok(())
}
-fn validate_manifest(
- manifest: &serde_json::Value,
- args: &ValidateArgs,
- result: &mut ValidationResult,
-) {
- let obj = match manifest.as_object() {
- Some(o) => o,
- None => {
- result
- .errors
- .push("composer.json must be a JSON object".to_string());
- return;
- }
- };
-
- check_name(obj, result);
- check_license(obj, result);
-
- if !args.no_check_version {
- check_version_field(obj, result);
- }
-
- check_package_type(obj, result);
- check_require_overlap(obj, result);
- check_provide_replace_overlap(obj, result);
- check_commit_references(obj, result);
- check_empty_psr_prefixes(obj, result);
- check_minimum_stability(obj, result);
- check_scripts_orphans(obj, result);
-}
-
-/// Check the "name" field: must be present (for published packages) and lowercase.
-fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) {
- match obj.get("name").and_then(|v| v.as_str()) {
- None => {
- result.publish_errors.push(
- "The name property is not set. This is required for published packages."
- .to_string(),
- );
- }
- Some(name) => {
- // Uppercase characters are a publish error
- if name.chars().any(|c| c.is_ascii_uppercase()) {
- let suggested = name
- .split('/')
- .map(mozart_core::validation::sanitize_package_name_component)
- .collect::<Vec<_>>()
- .join("/");
- result.publish_errors.push(format!(
- "Name \"{name}\" does not match the best practice (e.g. lower-cased/with-dashes). \
- We suggest using \"{suggested}\" instead. As such you will not be able to submit it to Packagist."
- ));
- }
-
- // Must contain a slash (vendor/package format)
- if !name.is_empty()
- && !mozart_core::validation::validate_package_name(name)
- && !name.contains('/')
- {
- result.errors.push(format!(
- "The name \"{name}\" is invalid, it should be in the format \"vendor/package\"."
- ));
- }
- }
- }
-}
-
-/// Check the "license" field. Mirrors:
-/// * Composer's `Util\ConfigValidator::validate()` — "No license" + deprecation
-/// warnings (see fix B for these).
-/// * Composer's `Package\Loader\ValidatingArrayLoader::load()` license block —
-/// type-shape warnings, SPDX expression validity, and extra-spaces detection.
-/// The validity/extra-spaces checks are gated on the `time` field: only
-/// releases without a date or within the last 8 days are checked.
-fn check_license(obj: &serde_json::Map<String, serde_json::Value>, result: &mut ValidationResult) {
- let no_license_msg = "No license specified, it is recommended to do so. \
- For closed-source software you may use \"proprietary\" as license."
- .to_string();
-
- // Composer's `empty($manifest['license'])` is true for missing, null, "", and [].
- let raw_entries: Vec<&serde_json::Value> = match obj.get("license") {
- None | Some(serde_json::Value::Null) => {
- result.warnings.push(no_license_msg);
- return;
- }
- Some(v @ serde_json::Value::String(s)) => {
- if s.is_empty() {
- result.warnings.push(no_license_msg);
- return;
- }
- vec![v]
- }
- Some(serde_json::Value::Array(arr)) => {
- if arr.is_empty() {
- result.warnings.push(no_license_msg);
- return;
- }
- arr.iter().collect()
- }
- // ValidatingArrayLoader: license must be a string or array of strings.
- Some(other) => {
- result.warnings.push(format!(
- "License must be a string or array of strings, got {}.",
- serde_json::to_string(other).unwrap_or_default()
- ));
- return;
- }
- };
-
- // ValidatingArrayLoader: each entry must be a string. Non-string entries
- // are dropped from the working set with a per-entry warning.
- let mut licenses: Vec<&str> = Vec::with_capacity(raw_entries.len());
- for v in raw_entries {
- match v.as_str() {
- Some(s) => licenses.push(s),
- None => result.warnings.push(format!(
- "License {} should be a string.",
- serde_json::to_string(v).unwrap_or_default()
- )),
- }
- }
-
- // ConfigValidator: deprecated identifier warnings.
- let spdx = mozart_spdx_licenses::spdx();
- for license in &licenses {
- if *license == "proprietary" {
- continue;
- }
- let Some(info) = spdx.get_license_by_identifier(license) else {
- continue;
- };
- if !info.deprecated {
- continue;
- }
- let warning = if DEPRECATED_GPL_OR_LATER_RE.is_match(license) {
- let suggested = format!("{}-or-later", license.replace('+', ""));
- format!(
- "License \"{license}\" is a deprecated SPDX license identifier, use \"{suggested}\" instead"
- )
- } else if DEPRECATED_GPL_BARE_RE.is_match(license) {
- format!(
- "License \"{license}\" is a deprecated SPDX license identifier, use \"{license}-only\" or \"{license}-or-later\" instead"
- )
- } else {
- format!(
- "License \"{license}\" is a deprecated SPDX license identifier, see https://spdx.org/licenses/"
- )
- };
- result.warnings.push(warning);
- }
-
- // ValidatingArrayLoader: SPDX expression validity, gated on the 8-day
- // release window. If `time` is absent or unparseable, releaseDate is
- // treated as null (PHP `try/catch` semantics) → validation runs.
- let release_ts = obj
- .get("time")
- .and_then(|v| v.as_str())
- .and_then(parse_iso_time_to_unix);
- let cutoff = current_unix_time().saturating_sub(8 * 86_400);
- let in_window = release_ts.is_none_or(|ts| ts >= cutoff);
- if !in_window {
- return;
- }
- for license in &licenses {
- if *license == "proprietary" {
- continue;
- }
- let to_validate = license.replace("proprietary", "MIT");
- if mozart_core::validation::validate_license(&to_validate) {
- continue;
- }
- // PHP `json_encode($license)` for a string is `"escaped"`; serde matches.
- let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\""));
- if mozart_core::validation::validate_license(to_validate.trim()) {
- result.warnings.push(format!(
- "License {quoted} must not contain extra spaces, make sure to trim it."
- ));
- } else {
- result.warnings.push(format!(
- "License {quoted} is not a valid SPDX license identifier, see https://spdx.org/licenses/ if you use an open license.\n\
- If the software is closed-source, you may use \"proprietary\" as license."
- ));
- }
- }
-}
-
-/// Current time as a Unix timestamp (UTC seconds since epoch). 0 if the
-/// system clock is set before the epoch.
-fn current_unix_time() -> i64 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_secs() as i64)
- .unwrap_or(0)
-}
-
-/// Parse a Composer-style `time` string into a Unix timestamp.
-///
-/// Accepts `YYYY-MM-DD HH:MM:SS` (Composer's typical output) and
-/// `YYYY-MM-DDTHH:MM:SS` with an optional `Z` or numeric offset suffix. The
-/// timezone suffix is parsed when present; absent suffixes are treated as
-/// UTC, matching `new DateTime($time, new DateTimeZone('UTC'))`.
-fn parse_iso_time_to_unix(s: &str) -> Option<i64> {
- let bytes = s.as_bytes();
- if bytes.len() < 19 {
- return None;
- }
- let n = |start: usize, len: usize| -> Option<i64> {
- std::str::from_utf8(&bytes[start..start + len])
- .ok()?
- .parse()
- .ok()
- };
- let year = n(0, 4)? as i32;
- if bytes[4] != b'-' {
- return None;
- }
- let month = n(5, 2)? as i32;
- if bytes[7] != b'-' {
- return None;
- }
- let day = n(8, 2)? as i32;
- if bytes[10] != b' ' && bytes[10] != b'T' {
- return None;
- }
- let hour = n(11, 2)?;
- if bytes[13] != b':' {
- return None;
- }
- let minute = n(14, 2)?;
- if bytes[16] != b':' {
- return None;
- }
- let second = n(17, 2)?;
-
- // Optional timezone suffix.
- let mut tz_offset_seconds: i64 = 0;
- if bytes.len() > 19 {
- let suffix = &s[19..];
- if suffix == "Z" {
- tz_offset_seconds = 0;
- } else {
- let body = suffix
- .strip_prefix('+')
- .or_else(|| suffix.strip_prefix('-'))?;
- let sign = if suffix.starts_with('+') { 1 } else { -1 };
- let body: String = body.chars().filter(|c| *c != ':').collect();
- if body.len() < 4 {
- return None;
- }
- let oh: i64 = body.get(0..2)?.parse().ok()?;
- let om: i64 = body.get(2..4)?.parse().ok()?;
- tz_offset_seconds = sign * (oh * 3600 + om * 60);
- }
- }
-
- let utc = days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second
- - tz_offset_seconds;
- Some(utc)
-}
-
-/// Howard Hinnant's `days_from_civil`: returns days since 1970-01-01 for a
-/// proleptic Gregorian (year, month, day). Handles negative years correctly.
-fn days_from_civil(y: i32, m: i32, d: i32) -> i64 {
- let y = if m <= 2 { y - 1 } else { y };
- let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
- let yoe = (y - era * 400) as i64; // [0, 399]
- let m_adj = if m > 2 { m - 3 } else { m + 9 };
- let doy = (153 * m_adj as i64 + 2) / 5 + d as i64 - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- (era as i64) * 146_097 + doe - 719_468
-}
-
-/// Warn if the "version" field is present.
-fn check_version_field(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- if obj.contains_key("version") {
- result.warnings.push(
- "The version field is present, it is recommended to leave it out \
- if the package is published on Packagist."
- .to_string(),
- );
- }
-}
-
-/// Warn if the package type is the deprecated "composer-installer".
-fn check_package_type(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- if let Some(pkg_type) = obj.get("type").and_then(|v| v.as_str())
- && pkg_type == "composer-installer"
- {
- result.warnings.push(
- "The package type 'composer-installer' is deprecated. \
- Please distribute your custom installers as plugins from now on. \
- See https://getcomposer.org/doc/articles/plugins.md for plugin documentation."
- .to_string(),
- );
- }
-}
-
-/// Warn if the same package appears in both require and require-dev.
-fn check_require_overlap(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- let require = obj.get("require").and_then(|v| v.as_object());
- let require_dev = obj.get("require-dev").and_then(|v| v.as_object());
-
- if let (Some(req), Some(req_dev)) = (require, require_dev) {
- let mut overlaps: Vec<&str> = Vec::new();
- for key in req.keys() {
- if req_dev.contains_key(key) {
- overlaps.push(key.as_str());
- }
- }
- if !overlaps.is_empty() {
- let plural = if overlaps.len() > 1 { "are" } else { "is" };
- result.warnings.push(format!(
- "{} {plural} required both in require and require-dev, \
- this can lead to unexpected behavior",
- overlaps.join(", "),
- ));
- }
- }
-}
-
-/// Warn if a package listed in provide/replace is also in require/require-dev.
-fn check_provide_replace_overlap(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- for link_type in &["provide", "replace"] {
- if let Some(links) = obj.get(*link_type).and_then(|v| v.as_object()) {
- for require_type in &["require", "require-dev"] {
- if let Some(requires) = obj.get(*require_type).and_then(|v| v.as_object()) {
- for provide_name in links.keys() {
- if requires.contains_key(provide_name) {
- result.warnings.push(format!(
- "The package {provide_name} in {require_type} is also listed in \
- {link_type} which satisfies the requirement. Remove it from \
- {link_type} if you wish to install it."
- ));
- }
- }
- }
- }
- }
- }
-}
-
-/// Warn about version constraints containing '#' (commit references).
-fn check_commit_references(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- for section in &["require", "require-dev"] {
- if let Some(deps) = obj.get(*section).and_then(|v| v.as_object()) {
- for (package, version) in deps {
- if let Some(v) = version.as_str()
- && v.contains('#')
- {
- result.warnings.push(format!(
- "The package \"{package}\" is pointing to a commit-ref, \
- this is bad practice and can cause unforeseen issues."
- ));
- }
- }
- }
- }
-}
-
-/// Warn about empty PSR-0/PSR-4 namespace prefixes (performance impact).
-fn check_empty_psr_prefixes(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- if let Some(autoload) = obj.get("autoload").and_then(|v| v.as_object()) {
- if let Some(psr0) = autoload.get("psr-0").and_then(|v| v.as_object())
- && psr0.contains_key("")
- {
- result.warnings.push(
- "Defining autoload.psr-0 with an empty namespace prefix is a bad idea \
- for performance"
- .to_string(),
- );
- }
- if let Some(psr4) = autoload.get("psr-4").and_then(|v| v.as_object())
- && psr4.contains_key("")
- {
- result.warnings.push(
- "Defining autoload.psr-4 with an empty namespace prefix is a bad idea \
- for performance"
- .to_string(),
- );
- }
- }
-}
-
-/// Check minimum-stability value if present.
-fn check_minimum_stability(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- if let Some(stability) = obj.get("minimum-stability").and_then(|v| v.as_str())
- && !mozart_core::validation::validate_stability(stability)
- {
- result.errors.push(format!(
- "The minimum-stability \"{stability}\" is invalid. \
- Must be one of: dev, alpha, beta, rc, stable."
- ));
- }
-}
-
-/// Warn about keys in scripts-descriptions or scripts-aliases that have no matching script.
-fn check_scripts_orphans(
- obj: &serde_json::Map<String, serde_json::Value>,
- result: &mut ValidationResult,
-) {
- let script_keys: indexmap::IndexSet<&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\""
- ));
- }
- }
- }
-}
-
fn validate_dependencies(
vendor_dir: &Path,
args: &ValidateArgs,
@@ -690,8 +212,7 @@ fn validate_dependencies(
continue;
};
- let mut result = ValidationResult::new();
- validate_manifest(&json_value, args, &mut result);
+ let result = validate_manifest(&json_value, &options_from_args(args));
dep_count += 1;
@@ -891,556 +412,6 @@ mod tests {
}
}
- fn parse_and_validate(json: &str, args: &ValidateArgs) -> ValidationResult {
- let value: serde_json::Value = serde_json::from_str(json).unwrap();
- let mut result = ValidationResult::new();
- validate_manifest(&value, args, &mut result);
- result
- }
-
- #[test]
- fn test_validate_missing_name_is_publish_error() {
- let json = r#"{"require": {"php": ">=8.1"}, "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.errors.is_empty());
- assert!(!result.publish_errors.is_empty());
- assert!(result.publish_errors[0].contains("name property is not set"));
- }
-
- #[test]
- fn test_validate_uppercase_name_publish_error() {
- let json = r#"{"name": "Vendor/Package", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.publish_errors.is_empty());
- assert!(result.publish_errors[0].contains("does not match the best practice"));
- assert!(result.publish_errors[0].contains("vendor/package"));
- }
-
- #[test]
- fn test_validate_uppercase_name_camel_case_to_dashes() {
- let json = r#"{"name": "MyCompany/MyLibrary", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.publish_errors.is_empty());
- assert!(
- result.publish_errors[0].contains("my-company/my-library"),
- "expected CamelCase-to-dashes conversion, got: {}",
- result.publish_errors[0]
- );
- }
-
- #[test]
- fn test_validate_valid_name_no_publish_error() {
- let json = r#"{"name": "vendor/package", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.publish_errors.is_empty());
- assert!(result.errors.is_empty());
- }
-
- #[test]
- fn test_validate_name_without_slash_is_error() {
- let json = r#"{"name": "novendor", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.errors.is_empty());
- assert!(result.errors[0].contains("vendor/package"));
- }
-
- #[test]
- fn test_validate_missing_license_warns() {
- let json = r#"{"name": "vendor/pkg"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.is_empty());
- assert!(result.warnings.iter().any(|w| w.contains("No license")));
- }
-
- #[test]
- fn test_validate_present_license_no_warning() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.iter().any(|w| w.contains("No license")));
- }
-
- #[test]
- fn test_validate_empty_license_string_warns() {
- let json = r#"{"name": "vendor/pkg", "license": ""}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("No license")));
- }
-
- #[test]
- fn test_validate_empty_license_array_warns() {
- let json = r#"{"name": "vendor/pkg", "license": []}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("No license")));
- }
-
- #[test]
- fn test_validate_proprietary_license_no_warning() {
- let json = r#"{"name": "vendor/pkg", "license": "proprietary"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.iter().any(|w| w.contains("license")));
- }
-
- #[test]
- fn test_validate_unknown_license_no_deprecation_warning() {
- // Unknown identifiers don't get a deprecation warning here (Composer's
- // ConfigValidator only checks deprecation, not validity).
- let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("deprecated SPDX"))
- );
- }
-
- #[test]
- fn test_validate_deprecated_gpl_bare_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "GPL-2.0"}"#;
- let result = parse_and_validate(json, &make_args());
- let warning = result
- .warnings
- .iter()
- .find(|w| w.contains("deprecated SPDX"))
- .expect("expected deprecation warning");
- assert!(warning.contains(r#""GPL-2.0""#), "got: {warning}");
- assert!(warning.contains("\"GPL-2.0-only\""), "got: {warning}");
- assert!(warning.contains("\"GPL-2.0-or-later\""), "got: {warning}");
- }
-
- #[test]
- fn test_validate_deprecated_gpl_or_later_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "GPL-3.0+"}"#;
- let result = parse_and_validate(json, &make_args());
- let warning = result
- .warnings
- .iter()
- .find(|w| w.contains("deprecated SPDX"))
- .expect("expected deprecation warning");
- assert!(warning.contains(r#""GPL-3.0+""#), "got: {warning}");
- assert!(warning.contains("\"GPL-3.0-or-later\""), "got: {warning}");
- }
-
- #[test]
- fn test_validate_deprecated_agpl_bare_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "AGPL-1.0"}"#;
- let result = parse_and_validate(json, &make_args());
- let warning = result
- .warnings
- .iter()
- .find(|w| w.contains("deprecated SPDX"))
- .expect("expected deprecation warning");
- assert!(warning.contains("\"AGPL-1.0-only\""), "got: {warning}");
- assert!(warning.contains("\"AGPL-1.0-or-later\""), "got: {warning}");
- }
-
- #[test]
- fn test_validate_deprecated_non_gpl_uses_generic_message() {
- // Pick any non-GPL deprecated identifier; eCos-2.0 was deprecated in SPDX.
- // If this changes upstream, replace with another deprecated id.
- let json = r#"{"name": "vendor/pkg", "license": "eCos-2.0"}"#;
- let result = parse_and_validate(json, &make_args());
- if let Some(warning) = result
- .warnings
- .iter()
- .find(|w| w.contains("deprecated SPDX"))
- {
- assert!(
- warning.contains("https://spdx.org/licenses/"),
- "expected generic message, got: {warning}"
- );
- }
- }
-
- #[test]
- fn test_validate_array_license_checks_each() {
- let json = r#"{"name": "vendor/pkg", "license": ["MIT", "GPL-2.0"]}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("deprecated SPDX") && w.contains("GPL-2.0")),
- "expected deprecation warning for GPL-2.0 in array form, got: {:?}",
- result.warnings,
- );
- }
-
- #[test]
- fn test_validate_non_deprecated_license_no_warning() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("deprecated SPDX")),
- "MIT is not deprecated, should not warn",
- );
- }
-
- #[test]
- fn test_validate_license_wrong_type_warns() {
- let json = r#"{"name": "vendor/pkg", "license": 42}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result.warnings.iter().any(|w| w
- .contains("License must be a string or array of strings")
- && w.contains("42")),
- "got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_array_non_string_entry_warns() {
- let json = r#"{"name": "vendor/pkg", "license": ["MIT", 42]}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("License 42 should be a string")),
- "got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_invalid_spdx_license_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "totally-not-a-license"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("\"totally-not-a-license\"")
- && w.contains("not a valid SPDX license identifier")),
- "got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_extra_spaces_warns() {
- // Composer's expression regex tolerates one ASCII space on each end —
- // the warning only fires when there are more (or non-space whitespace).
- let json = r#"{"name": "vendor/pkg", "license": " MIT "}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("must not contain extra spaces")),
- "got: {:?}",
- result.warnings
- );
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("not a valid SPDX")),
- "extra-spaces case should not also emit invalid-SPDX, got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_proprietary_in_expression_validates() {
- // Composer replaces "proprietary" with "MIT" before validating, so
- // expressions mixing "proprietary" with real identifiers are accepted.
- let json = r#"{"name": "vendor/pkg", "license": "(MIT OR proprietary)"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("not a valid SPDX")),
- "got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_old_release_skips_validity_check() {
- // Release older than 8 days → SPDX validity check is skipped, but
- // deprecation warnings (ConfigValidator's path) still fire.
- let json = r#"{
- "name": "vendor/pkg",
- "license": "totally-not-a-license",
- "time": "1970-01-01 00:00:00"
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("not a valid SPDX")),
- "old release should not produce SPDX validity warning, got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_recent_release_validates() {
- // A future "time" is within the cutoff (>= now-8days) → validate.
- let json = r#"{
- "name": "vendor/pkg",
- "license": "totally-not-a-license",
- "time": "9999-01-01 00:00:00"
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("not a valid SPDX")),
- "recent release should produce SPDX validity warning, got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_validate_license_unparseable_time_treated_as_null() {
- // Unparseable time → releaseDate stays null → validation runs.
- let json = r#"{
- "name": "vendor/pkg",
- "license": "totally-not-a-license",
- "time": "not-a-date"
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("not a valid SPDX")),
- "unparseable time should be treated as null → validate, got: {:?}",
- result.warnings
- );
- }
-
- #[test]
- fn test_parse_iso_time_basic() {
- assert_eq!(parse_iso_time_to_unix("1970-01-01 00:00:00"), Some(0));
- assert_eq!(
- parse_iso_time_to_unix("2023-12-15 13:45:30"),
- Some(1_702_647_930)
- );
- }
-
- #[test]
- fn test_parse_iso_time_t_separator() {
- assert_eq!(
- parse_iso_time_to_unix("2023-12-15T13:45:30"),
- Some(1_702_647_930)
- );
- assert_eq!(
- parse_iso_time_to_unix("2023-12-15T13:45:30Z"),
- Some(1_702_647_930)
- );
- }
-
- #[test]
- fn test_parse_iso_time_with_offset() {
- // 13:45:30+05:00 = 08:45:30 UTC
- assert_eq!(
- parse_iso_time_to_unix("2023-12-15T13:45:30+05:00"),
- Some(1_702_647_930 - 5 * 3600)
- );
- // 13:45:30-05:00 = 18:45:30 UTC
- assert_eq!(
- parse_iso_time_to_unix("2023-12-15T13:45:30-05:00"),
- Some(1_702_647_930 + 5 * 3600)
- );
- }
-
- #[test]
- fn test_parse_iso_time_invalid() {
- assert_eq!(parse_iso_time_to_unix(""), None);
- assert_eq!(parse_iso_time_to_unix("not-a-date"), None);
- assert_eq!(parse_iso_time_to_unix("2023-12-15"), None);
- assert_eq!(parse_iso_time_to_unix("2023/12/15 13:45:30"), None);
- }
-
- #[test]
- fn test_validate_version_field_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT", "version": "1.0.0"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("version field")));
- }
-
- #[test]
- fn test_validate_no_check_version_suppresses_warning() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT", "version": "1.0.0"}"#;
- let mut args = make_args();
- args.no_check_version = true;
- let result = parse_and_validate(json, &args);
- assert!(!result.warnings.iter().any(|w| w.contains("version field")));
- }
-
- #[test]
- fn test_validate_deprecated_type_warns() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT", "type": "composer-installer"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("composer-installer"))
- );
- }
-
- #[test]
- fn test_validate_normal_type_no_warning() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT", "type": "library"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- !result
- .warnings
- .iter()
- .any(|w| w.contains("composer-installer"))
- );
- }
-
- #[test]
- fn test_validate_require_overlap_warns() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "require": {"monolog/monolog": "^3.0"},
- "require-dev": {"monolog/monolog": "^3.0"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("required both in require and require-dev"))
- );
- }
-
- #[test]
- fn test_validate_no_require_overlap_no_warning() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "require": {"monolog/monolog": "^3.0"},
- "require-dev": {"phpunit/phpunit": "^10.0"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.iter().any(|w| w.contains("required both")));
- }
-
- #[test]
- fn test_validate_provide_replace_overlap_warns() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "require": {"psr/log": "^3.0"},
- "provide": {"psr/log": "^3.0"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(
- result
- .warnings
- .iter()
- .any(|w| w.contains("also listed in provide"))
- );
- }
-
- #[test]
- fn test_validate_commit_ref_warns() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "require": {"foo/bar": "dev-main#abc123"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("commit-ref")));
- }
-
- #[test]
- fn test_validate_normal_constraint_no_commit_warning() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "require": {"foo/bar": "^1.0"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.iter().any(|w| w.contains("commit-ref")));
- }
-
- #[test]
- fn test_validate_empty_psr4_prefix_warns() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "autoload": {"psr-4": {"": "src/"}}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("psr-4")));
- }
-
- #[test]
- fn test_validate_empty_psr0_prefix_warns() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "autoload": {"psr-0": {"": "src/"}}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.warnings.iter().any(|w| w.contains("psr-0")));
- }
-
- #[test]
- fn test_validate_named_psr4_prefix_no_warning() {
- let json = r#"{
- "name": "vendor/pkg",
- "license": "MIT",
- "autoload": {"psr-4": {"Vendor\\Pkg\\": "src/"}}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.warnings.iter().any(|w| w.contains("psr-4")));
- }
-
- #[test]
- fn test_validate_invalid_stability_errors() {
- let json = r#"{"name": "vendor/pkg", "license": "MIT", "minimum-stability": "invalid"}"#;
- let result = parse_and_validate(json, &make_args());
- assert!(!result.errors.is_empty());
- assert!(
- result
- .errors
- .iter()
- .any(|e| e.contains("minimum-stability"))
- );
- }
-
- #[test]
- fn test_validate_valid_stability_no_error() {
- for stab in &["dev", "alpha", "beta", "rc", "stable"] {
- let json = format!(
- r#"{{"name": "vendor/pkg", "license": "MIT", "minimum-stability": "{stab}"}}"#
- );
- let result = parse_and_validate(&json, &make_args());
- assert!(
- !result
- .errors
- .iter()
- .any(|e| e.contains("minimum-stability")),
- "stability '{stab}' should be valid"
- );
- }
- }
-
- #[test]
- fn test_validate_non_object_json_errors() {
- let value = serde_json::json!([1, 2, 3]);
- let mut result = ValidationResult::new();
- validate_manifest(&value, &make_args(), &mut result);
- assert!(result.errors.iter().any(|e| e.contains("JSON object")));
- }
-
#[test]
fn test_compute_exit_code_no_issues() {
let result = ValidationResult::new();
@@ -1597,78 +568,6 @@ mod tests {
}
#[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
- );
- }
-
- #[test]
fn test_should_check_lock_config_false_disables() {
let args = make_args();
let manifest = serde_json::json!({"config": {"lock": false}});
@@ -1689,26 +588,4 @@ mod tests {
let manifest = serde_json::json!({"name": "vendor/pkg"});
assert!(should_check_lock(&args, &manifest));
}
-
- #[test]
- fn test_validate_no_errors_on_valid_package() {
- let json = r#"{
- "name": "vendor/package",
- "description": "A test package",
- "license": "MIT",
- "require": {"php": ">=8.1"}
- }"#;
- let result = parse_and_validate(json, &make_args());
- assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
- assert!(
- result.publish_errors.is_empty(),
- "publish errors: {:?}",
- result.publish_errors
- );
- // Only the version-field warning might appear — but we have no version field here
- assert!(
- !result.warnings.iter().any(|w| w.contains("version field")),
- "unexpected version warning"
- );
- }
}