aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-01 21:49:37 +0900
committernsfisis <nsfisis@gmail.com>2026-05-01 21:49:37 +0900
commit740eb5b55804134c1977dd39cd8170e2fa615d35 (patch)
tree99bb3bf28a60ffac1db8fa6e523eb1828d40007b
parent2f857cc1ddfde54553829ea5b3a3ac8b59f7f63a (diff)
downloadphp-mozart-740eb5b55804134c1977dd39cd8170e2fa615d35.tar.gz
php-mozart-740eb5b55804134c1977dd39cd8170e2fa615d35.tar.zst
php-mozart-740eb5b55804134c1977dd39cd8170e2fa615d35.zip
feat(core): reject root composer.json that requires its own name
Mirrors Composer\Package\Loader\RootPackageLoader::load(): if the root package's "name" appears as a key in its own "require" or "require-dev" map, fail loudly before reaching the resolver. Without this, Mozart would silently let the request hit Packagist (which has no entry for the root's vendor/name) and report a misleading "could not be found" error. Wired into install::execute (when a lock file is present) and update::execute (the no-lock fallback path). Carries the same wording as Composer's RuntimeException so a future EXPECT-OUTPUT comparison will match. Also extends the installer test harness: when a fixture sets EXPECT-EXCEPTION but no EXPECT-EXIT-CODE, assert that Mozart exits non-zero. Full exception-class matching remains a follow-up (see .ken/test_design.md ยง7.2). Closes the gap exercised by the install-self-from-root installer fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/mozart-core/src/package.rs54
-rw-r--r--crates/mozart/src/commands/install.rs1
-rw-r--r--crates/mozart/src/commands/update.rs1
-rw-r--r--crates/mozart/tests/installer.rs48
4 files changed, 91 insertions, 13 deletions
diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs
index 5906c7e..a45a480 100644
--- a/crates/mozart-core/src/package.rs
+++ b/crates/mozart-core/src/package.rs
@@ -559,6 +559,22 @@ impl RawPackageData {
extra_fields: BTreeMap::new(),
}
}
+
+ /// Reject root composer.json that requires its own package name.
+ ///
+ /// Mirrors the check in Composer's
+ /// `Composer\Package\Loader\RootPackageLoader::load()` (RuntimeException
+ /// thrown for `$config['require'][$config['name']]` /
+ /// `$config['require-dev'][$config['name']]`).
+ pub fn validate_root_does_not_self_require(&self) -> anyhow::Result<()> {
+ if self.require.contains_key(&self.name) || self.require_dev.contains_key(&self.name) {
+ anyhow::bail!(
+ "Root package '{}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?",
+ self.name
+ );
+ }
+ Ok(())
+ }
}
pub fn read_from_file(path: &Path) -> anyhow::Result<RawPackageData> {
@@ -719,4 +735,42 @@ mod tests {
assert!(!json.contains("\"repositories\""));
assert!(!json.contains("\"autoload\""));
}
+
+ #[test]
+ fn validate_root_self_require_rejects_self_in_require() {
+ let mut raw = RawPackageData::new("foo/bar".to_string());
+ raw.require
+ .insert("foo/bar".to_string(), "@dev".to_string());
+ let err = raw.validate_root_does_not_self_require().unwrap_err();
+ let msg = err.to_string();
+ assert!(
+ msg.contains("Root package 'foo/bar' cannot require itself"),
+ "{msg}"
+ );
+ assert!(msg.contains("accidentally name your root package"), "{msg}");
+ }
+
+ #[test]
+ fn validate_root_self_require_rejects_self_in_require_dev() {
+ let mut raw = RawPackageData::new("foo/bar".to_string());
+ raw.require_dev
+ .insert("foo/bar".to_string(), "@dev".to_string());
+ assert!(raw.validate_root_does_not_self_require().is_err());
+ }
+
+ #[test]
+ fn validate_root_self_require_accepts_other_packages() {
+ let mut raw = RawPackageData::new("foo/bar".to_string());
+ raw.require
+ .insert("vendor/lib".to_string(), "^1.0".to_string());
+ raw.require_dev
+ .insert("vendor/dev-tool".to_string(), "^2.0".to_string());
+ assert!(raw.validate_root_does_not_self_require().is_ok());
+ }
+
+ #[test]
+ fn validate_root_self_require_accepts_empty_requires() {
+ let raw = RawPackageData::new("foo/bar".to_string());
+ assert!(raw.validate_root_does_not_self_require().is_ok());
+ }
}
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index f920f02..b9ff1af 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -809,6 +809,7 @@ pub async fn execute(
}
let root_pkg = mozart_core::package::read_from_file(&composer_json_path)?;
+ root_pkg.validate_root_does_not_self_require()?;
let missing = lock.get_missing_requirement_info(&root_pkg, dev_mode);
if !missing.is_empty() {
for line in &missing {
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index da2fd95..3a468d4 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -747,6 +747,7 @@ pub async fn execute(
));
}
let composer_json = package::read_from_file(&composer_json_path)?;
+ composer_json.validate_root_does_not_self_require()?;
let composer_json_content = std::fs::read_to_string(&composer_json_path)?;
let lock_path = working_dir.join("composer.lock");
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index f3eeabc..5d83bac 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -14,15 +14,40 @@ fn run_installer_fixture(ident: &str) {
let mozart_bin: &Path = assert_cmd::cargo::cargo_bin!("mozart");
let result = run_test(&parsed, mozart_bin)
.unwrap_or_else(|e| panic!("failed to run {}: {:#}", path.display(), e));
- let expected = parsed.expect_exit_code.unwrap_or(0);
- assert_eq!(
- result.exit_code,
- expected,
- "exit code mismatch for {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
- path.display(),
- result.stdout,
- result.stderr,
- );
+
+ // Composer's `.test` format uses EXPECT-EXCEPTION to assert that the run
+ // throws an exception. PHP propagates uncaught exceptions as a non-zero
+ // exit; we don't yet match the exception class, but we do require Mozart
+ // to exit non-zero when the fixture expects an exception (and no explicit
+ // EXPECT-EXIT-CODE has been pinned).
+ if let Some(code) = parsed.expect_exit_code {
+ assert_eq!(
+ result.exit_code,
+ code,
+ "exit code mismatch for {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
+ path.display(),
+ result.stdout,
+ result.stderr,
+ );
+ } else if parsed.expect_exception.is_some() {
+ assert_ne!(
+ result.exit_code,
+ 0,
+ "expected non-zero exit (EXPECT-EXCEPTION) for {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
+ path.display(),
+ result.stdout,
+ result.stderr,
+ );
+ } else {
+ assert_eq!(
+ result.exit_code,
+ 0,
+ "exit code mismatch for {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
+ path.display(),
+ result.stdout,
+ result.stderr,
+ );
+ }
}
macro_rules! installer_fixture {
@@ -281,10 +306,7 @@ installer_fixture!(
install_security_advisory_matching_dependency,
ignore = "mozart binary cannot yet run this fixture"
);
-installer_fixture!(
- install_self_from_root,
- ignore = "mozart binary cannot yet run this fixture"
-);
+installer_fixture!(install_self_from_root);
installer_fixture!(
install_simple,
ignore = "mozart binary cannot yet run this fixture"