diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-01 21:49:37 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-01 21:49:37 +0900 |
| commit | 740eb5b55804134c1977dd39cd8170e2fa615d35 (patch) | |
| tree | 99bb3bf28a60ffac1db8fa6e523eb1828d40007b | |
| parent | 2f857cc1ddfde54553829ea5b3a3ac8b59f7f63a (diff) | |
| download | php-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.rs | 54 | ||||
| -rw-r--r-- | crates/mozart/src/commands/install.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/tests/installer.rs | 48 |
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" |
