From 740eb5b55804134c1977dd39cd8170e2fa615d35 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 1 May 2026 21:49:37 +0900 Subject: feat(core): reject root composer.json that requires its own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/mozart-core/src/package.rs | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) (limited to 'crates/mozart-core/src') 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 { @@ -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()); + } } -- cgit v1.3.1