mod dependency_versions;
mod preconditions;
mod wrapper_features;
use super::extras::Language;
use super::output::validate_output_segment;
use super::resolved::ResolvedCrateConfig;
use crate::core::error::AlefError;
use preconditions::{test_main_fields, validate_section, validate_test_e2e_precondition, validate_tools};
pub fn validate_resolved(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
validate_wrapper_default_features(config)?;
validate_tools(&config.tools)?;
validate_package_metadata(config)?;
validate_e2e_env_keys(config)?;
validate_rust_handle_teardown(config)?;
validate_extra_lint_paths(config)?;
dependency_versions::validate_dependency_version_overrides(config)?;
validate_section("test", &config.test, test_main_fields, |c| c.precondition.as_deref())?;
validate_test_e2e_precondition(&config.test)?;
validate_trait_bridges(config)?;
validate_dart_library_name(config)?;
Ok(())
}
fn validate_rust_handle_teardown(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
let Some(e2e) = &config.e2e else {
return Ok(());
};
for (call_name, call) in
std::iter::once(("call", &e2e.call)).chain(e2e.calls.iter().map(|(name, call)| (name.as_str(), call)))
{
let Some(value) = call
.overrides
.get("rust")
.and_then(|rust| rust.handle_teardown.as_deref())
else {
continue;
};
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed != value
|| !crate::core::keywords::is_valid_rust_ident_chars(trimmed)
|| crate::core::keywords::RUST_KEYWORDS.contains(&trimmed)
{
return Err(AlefError::Config(format!(
"crate `{}`: e2e.{call_name}.overrides.rust.handle_teardown value `{value}` must be a bare, non-keyword Rust identifier without surrounding whitespace",
config.name
)));
}
}
Ok(())
}
pub(crate) fn validate_wrapper_default_features(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
wrapper_features::validate(config).map_err(AlefError::Config)
}
pub(crate) fn validate_wrapper_default_feature_name(feature: &str) -> Result<(), String> {
wrapper_features::validate_name(feature)
}
fn validate_dart_library_name(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
if !config.targets(Language::Dart) {
return Ok(());
}
validate_output_segment(&config.dart_library_name(), "dart.lib_name (derived from crate name)")
.map_err(|detail| AlefError::Config(format!("crate `{}`: {detail}", config.name)))
}
fn validate_trait_bridges(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
if config.targets(Language::Php) {
for bridge in config.trait_bridges_for(Language::Php) {
if !bridge.php_callbacks_unsupported() {
return Err(AlefError::Config(bridge.php_safety_error()));
}
if let Some(error) = bridge.php_lifecycle_only_error() {
return Err(AlefError::Config(error));
}
}
}
for bridge in config.all_trait_bridges() {
if bridge.register_fn.is_some() && bridge.registry_getter.is_none() {
let unsupported_languages = config
.languages
.iter()
.filter(|language| **language != Language::Ruby)
.filter(|language| {
config.trait_bridges_for(**language).any(|active_bridge| {
std::ptr::eq(active_bridge, bridge)
&& !(**language == Language::Php && bridge.php_callbacks_unsupported())
})
})
.map(ToString::to_string)
.collect::<Vec<_>>();
if !unsupported_languages.is_empty() {
return Err(AlefError::Config(format!(
"trait bridge `{}` sets `register_fn` but no `registry_getter`; direct host \
registration is not supported for active language(s): {}. Add \
`registry_getter`, exclude those languages from this bridge, or drop \
`register_fn`.",
bridge.trait_name,
unsupported_languages.join(", ")
)));
}
}
}
Ok(())
}
pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn validate_e2e_env_keys(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
let Some(e2e) = &config.e2e else {
return Ok(());
};
let mut invalid: Vec<&str> = e2e
.env
.keys()
.map(String::as_str)
.filter(|key| !is_valid_env_var_name(key))
.collect();
if invalid.is_empty() {
return Ok(());
}
invalid.sort_unstable();
Err(AlefError::Config(format!(
"invalid `[crates.e2e.env]` key(s) for crate `{}`: {}. Environment variable names must \
match `[A-Za-z_][A-Za-z0-9_]*` -- they are forwarded into shell commands and into \
several generated languages' own source, and a name outside this pattern cannot be \
expressed safely in all of them.",
config.name,
invalid.join(", ")
)))
}
fn is_well_formed_path_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')
}
fn validate_extra_lint_paths(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
let mut invalid: Vec<String> = Vec::new();
for lang in Language::ALL {
for path in config.extra_lint_paths_for_language(lang) {
if path.is_empty() || !path.chars().all(is_well_formed_path_char) {
invalid.push(format!("{lang}: {path:?}"));
}
}
}
if invalid.is_empty() {
return Ok(());
}
Err(AlefError::Config(format!(
"invalid `extra_lint_paths` entries for crate `{}`: {}. Entries are appended verbatim \
into a lint/format shell command with no quoting, so they must match \
`[A-Za-z0-9._/-]+` -- no whitespace or shell metacharacters.",
config.name,
invalid.join(", ")
)))
}
fn validate_package_metadata(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
const CRATES_IO_LIST_LIMIT: usize = 5;
let Some(meta) = &config.package_metadata else {
return Ok(());
};
if !meta.truncate_registry_lists {
if meta.keywords.len() > CRATES_IO_LIST_LIMIT {
return Err(AlefError::Config(format!(
"crate `{}` package_metadata.keywords has {} entries; crates.io supports at most {CRATES_IO_LIST_LIMIT}. \
Reduce the list or set package_metadata.truncate_registry_lists = true.",
config.name,
meta.keywords.len()
)));
}
if meta.categories.len() > CRATES_IO_LIST_LIMIT {
return Err(AlefError::Config(format!(
"crate `{}` package_metadata.categories has {} entries; crates.io supports at most {CRATES_IO_LIST_LIMIT}. \
Reduce the list or set package_metadata.truncate_registry_lists = true.",
config.name,
meta.categories.len()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::new_config::NewAlefConfig;
fn resolve_first(toml_str: &str) -> ResolvedCrateConfig {
let cfg: NewAlefConfig = toml::from_str(toml_str).expect("config should parse");
cfg.resolve().expect("config should resolve").remove(0)
}
fn base_config() -> &'static str {
r#"
[workspace]
languages = ["python"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
"#
}
#[test]
fn no_user_overrides_is_valid() {
let config = resolve_first(base_config());
validate_resolved(&config).expect("default config should validate");
}
#[test]
fn e2e_env_key_with_shell_syntax_is_rejected() {
let toml = format!(
"{base}\n[crates.e2e]\nfixtures = \"fixtures\"\noutput = \"e2e\"\n\
[crates.e2e.call]\nfunction = \"process\"\nmodule = \"test-lib\"\n\
[crates.e2e.env]\n\"MYVAR; touch pwned\" = \"safe\"\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("shell-shaped env key must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("MYVAR; touch pwned"),
"error should name the bad key: {msg}"
);
assert!(
msg.contains("[A-Za-z_][A-Za-z0-9_]*"),
"error should name the required pattern: {msg}"
);
}
#[test]
fn e2e_env_key_valid_identifier_is_accepted() {
let toml = format!(
"{base}\n[crates.e2e]\nfixtures = \"fixtures\"\noutput = \"e2e\"\n\
[crates.e2e.call]\nfunction = \"process\"\nmodule = \"test-lib\"\n\
[crates.e2e.env]\nALLOW_PRIVATE_NETWORK = \"true\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect("identifier-shaped env key should validate");
}
#[test]
fn extra_lint_paths_entry_with_shell_syntax_is_rejected() {
let toml = format!(
"{base}\n[crates.python]\nextra_lint_paths = [\"scripts; touch pwned\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("shell-shaped extra_lint_paths entry must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("scripts; touch pwned"),
"error should name the bad entry: {msg}"
);
assert!(
msg.contains("[A-Za-z0-9._/-]+"),
"error should name the required pattern: {msg}"
);
}
#[test]
fn extra_lint_paths_entry_valid_path_is_accepted() {
let toml = format!(
"{base}\n[crates.python]\nextra_lint_paths = [\"scripts/helpers.py\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect("path-shaped extra_lint_paths entry should validate");
}
#[test]
fn is_valid_env_var_name_accepts_identifiers_and_rejects_shell_syntax() {
for ok in ["MOCK_SERVER_URL", "_leading_underscore", "A1", "a"] {
assert!(is_valid_env_var_name(ok), "expected {ok:?} to be valid");
}
for bad in [
"",
"1LEADING_DIGIT",
"HAS-HYPHEN",
"HAS SPACE",
"HAS;SEMI",
"HAS'QUOTE",
"$(cmd)",
] {
assert!(!is_valid_env_var_name(bad), "expected {bad:?} to be rejected");
}
}
#[test]
fn is_well_formed_path_char_accepts_paths_and_rejects_shell_syntax() {
for ok in ["scripts/helpers.py", "a_b-c.d", "vendor/third_party"] {
assert!(
ok.chars().all(is_well_formed_path_char),
"expected {ok:?} to be all well-formed path chars"
);
}
for bad in [
"scripts; touch pwned",
"scripts`touch pwned`",
"scripts$(touch pwned)",
"has space",
"has'quote",
] {
assert!(
!bad.chars().all(is_well_formed_path_char),
"expected {bad:?} to contain a rejected char"
);
}
}
#[test]
fn test_override_with_main_cmd_no_precondition_errors() {
let toml = format!(
"{base}\n[crates.test.python]\ncommand = \"pytest\"\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("missing precondition should error");
assert!(format!("{err}").contains("[test.python]"));
}
#[test]
fn test_override_with_only_e2e_requires_precondition() {
let toml = format!(
"{base}\n[crates.test.python]\ne2e = \"pytest tests/e2e\"\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("e2e without precondition or e2e_precondition should error");
let msg = format!("{err}");
assert!(msg.contains("[test.python]"), "{msg}");
assert!(msg.contains("e2e_precondition"), "{msg}");
}
#[test]
fn test_override_with_only_e2e_and_e2e_precondition_is_ok() {
let toml = format!(
"{base}\n[crates.test.python]\ne2e_precondition = \"command -v uv\"\ne2e = \"pytest tests/e2e\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect("e2e with e2e_precondition alone should validate");
}
#[test]
fn test_override_with_e2e_and_command_needs_only_top_level_precondition() {
let toml = format!(
"{base}\n[crates.test.python]\nprecondition = \"command -v pytest\"\ncommand = \"pytest\"\ne2e = \
\"pytest tests/e2e\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config)
.expect("a top-level precondition still satisfies both command and e2e when no e2e_precondition is set");
}
#[test]
fn error_message_lists_only_actually_set_main_fields() {
let toml = format!(
"{base}\n[crates.test.python]\ncommand = \"pytest\"\n",
base = base_config()
);
let config = resolve_first(&toml);
let msg = format!("{}", validate_resolved(&config).unwrap_err());
assert!(msg.contains("`command`"), "expected `command`, got: {msg}");
assert!(
!msg.contains("`coverage`"),
"should not mention unset `coverage`: {msg}"
);
}
#[test]
fn before_plus_main_cmd_without_precondition_still_errors() {
let toml = format!(
"{base}\n[crates.test.python]\nbefore = \"echo hi\"\ncommand = \"pytest\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect_err("before + main without precondition must error");
}
#[test]
fn malformed_python_package_manager_value_is_rejected() {
let toml = format!(
"{base}\n[workspace.tools]\npython_package_manager = \"uv; rm -rf /\"\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
assert!(format!("{err}").contains("well-formed"));
}
#[test]
fn malformed_node_package_manager_value_is_rejected() {
let toml = format!(
"{base}\n[workspace.tools]\nnode_package_manager = \"pnpm$(echo bad)\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
}
#[test]
fn malformed_rust_dev_tool_entry_is_rejected() {
let toml = format!(
"{base}\n[workspace.tools]\nrust_dev_tools = [\"cargo-edit\", \"cargo`evil`\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
}
#[test]
fn whitespace_in_tool_name_is_rejected() {
let toml = format!(
"{base}\n[workspace.tools]\npython_package_manager = \"uv \"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect_err("trailing whitespace must be rejected");
}
#[test]
fn empty_tool_name_is_rejected() {
let toml = format!(
"{base}\n[workspace.tools]\npython_package_manager = \"\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect_err("empty tool name must be rejected");
}
#[test]
fn safe_tool_names_are_accepted() {
let toml = format!(
"{base}\n[workspace.tools]\npython_package_manager = \"uv\"\n\
node_package_manager = \"pnpm\"\n\
rust_dev_tools = [\"cargo-edit\", \"cargo_sort\", \"tool.v2\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect("normal tool names should validate");
}
#[test]
fn package_metadata_keywords_over_crates_io_limit_errors() {
let toml = format!(
"{base}\n[crates.package_metadata]\nkeywords = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
let err = validate_resolved(&config).expect_err("too many crates.io keywords should error");
let msg = format!("{err}");
assert!(msg.contains("package_metadata.keywords"), "got: {msg}");
assert!(msg.contains("at most 5"), "got: {msg}");
}
#[test]
fn package_metadata_can_opt_into_registry_list_truncation() {
let toml = format!(
"{base}\n[crates.package_metadata]\n\
truncate_registry_lists = true\n\
keywords = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"]\n\
categories = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\"]\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).expect("explicit truncation opt-in should validate");
}
#[test]
fn override_with_main_cmd_and_precondition_validates() {
let toml = format!(
"{base}\n[crates.test.python]\nprecondition = \"command -v tool\"\ncommand = \"tool run\"\n",
base = base_config()
);
let config = resolve_first(&toml);
validate_resolved(&config).unwrap_or_else(|e| panic!("[test] with precondition should validate: {e}"));
}
#[test]
fn crate_name_with_a_path_separator_is_rejected_before_the_dart_check_can_run() {
let toml_str = r#"
[workspace]
languages = ["dart"]
[[crates]]
name = "sample/evil"
sources = ["src/lib.rs"]
[crates.output]
dart = "packages/dart/lib/src"
"#;
let cfg: NewAlefConfig = toml::from_str(toml_str).expect("config should parse");
let err = cfg
.resolve()
.expect_err("a `/` in the crate name must be rejected at resolve time");
let message = err.to_string();
assert!(
message.contains("sample/evil"),
"error should name the crate: {message}"
);
assert!(
message.contains("invalid name"),
"error should point at the crate `name` field, not a Dart-specific one: {message}"
);
assert!(
message.contains("path separators are not allowed"),
"error should explain why: {message}"
);
}
#[test]
fn dart_library_name_accepts_an_ordinary_crate_name_when_unconfigured() {
let toml_str = r#"
[workspace]
languages = ["dart"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
"#;
let config = resolve_first(toml_str);
validate_resolved(&config).expect("an ordinary crate name must still validate");
}
#[test]
fn dart_library_name_is_not_checked_for_a_crate_that_does_not_target_dart() {
let toml_str = r#"
[workspace]
languages = ["python"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[crates.dart]
pubspec_name = "sample/evil"
[crates.output]
python = "packages/python"
"#;
let config = resolve_first(toml_str);
assert_eq!(
config.dart_library_name(),
"sample/evil",
"fixture must carry the hazard through unvalidated"
);
validate_resolved(&config).expect("a crate not targeting dart must not be checked");
}
#[test]
fn ruby_trait_bridge_accepts_direct_host_registration() {
let config = resolve_first(
r#"
[workspace]
languages = ["ruby"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[[crates.trait_bridges]]
trait_name = "SamplePlugin"
register_fn = "register_sample_plugin"
"#,
);
validate_resolved(&config).expect("Magnus supports direct core host registration");
}
#[test]
fn non_ruby_trait_bridge_rejects_direct_host_registration() {
let config = resolve_first(
r#"
[workspace]
languages = ["ruby", "python"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[[crates.trait_bridges]]
trait_name = "SamplePlugin"
register_fn = "register_sample_plugin"
"#,
);
let error = validate_resolved(&config).expect_err("Python still requires a registry getter");
assert!(
error.to_string().contains("active language(s): python"),
"error must identify the unsupported active language: {error}"
);
}
#[test]
fn direct_host_validation_honors_backend_alias_exclusions() {
let config = resolve_first(
r#"
[workspace]
languages = ["ruby", "python"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[[crates.trait_bridges]]
trait_name = "SamplePlugin"
register_fn = "register_sample_plugin"
exclude_languages = ["pyo3"]
"#,
);
validate_resolved(&config).expect("the pyo3 alias excludes Python, leaving only direct-host-capable Ruby");
}
fn php_bridge_config(bridge: &str) -> ResolvedCrateConfig {
resolve_first(&format!(
r#"
[workspace]
languages = ["php"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[[crates.trait_bridges]]
trait_name = "SamplePlugin"
{bridge}
"#
))
}
#[test]
fn php_rejects_every_zend_object_trait_bridge_shape() {
let cases = [
(
"registry",
r#"super_trait = "Plugin"
register_fn = "register_sample_plugin"
registry_getter = "sample_core::plugins::registry""#,
),
("direct host registration", r#"register_fn = "register_sample_plugin""#),
(
"function parameter",
r#"type_alias = "SamplePluginHandle"
param_name = "plugin"
bind_via = "function_param""#,
),
(
"options field",
r#"type_alias = "SamplePluginHandle"
param_name = "plugin"
bind_via = "options_field"
options_type = "SampleOptions"
options_field = "plugin""#,
),
(
"visitor",
r#"type_alias = "VisitorHandle"
param_name = "visitor"
context_type = "NodeContext"
result_type = "VisitResult""#,
),
];
for (shape, bridge) in cases {
let config = php_bridge_config(bridge);
let error = match validate_resolved(&config) {
Ok(()) => panic!("PHP {shape} bridge unexpectedly validated"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("PHP trait bridge `SamplePlugin` is disabled"),
"{shape} must fail at the Zend lifetime safety gate: {error}"
);
}
}
#[test]
fn php_safety_error_recommends_only_migrations_valid_for_the_bridge_shape() {
let visitor = php_bridge_config(
r#"type_alias = "VisitorHandle"
param_name = "visitor"
bind_via = "options_field"
options_type = "SampleOptions"
options_field = "visitor"
context_type = "NodeContext"
result_type = "VisitResult""#,
);
let visitor_message = validate_resolved(&visitor)
.expect_err("an attached PHP visitor must fail closed")
.to_string();
assert!(
visitor_message.contains("`php:callbacks` cannot preserve this visitor callback"),
"the diagnostic must not recommend an incompatible lifecycle-only mode: {visitor_message}"
);
assert!(
visitor_message.contains("omit the PHP callback interface and its options-field carrier"),
"the diagnostic must state what plain `php` removes: {visitor_message}"
);
assert!(
!visitor_message.contains("as `php:callbacks` to preserve"),
"the diagnostic must not present `php:callbacks` as a valid migration: {visitor_message}"
);
let lifecycle = php_bridge_config(
r#"super_trait = "Plugin"
register_fn = "register_sample_plugin"
registry_getter = "sample_core::plugins::registry""#,
);
let lifecycle_message = validate_resolved(&lifecycle)
.expect_err("an enabled PHP lifecycle bridge must fail closed")
.to_string();
assert!(
lifecycle_message.contains("as `php:callbacks` to preserve its public lifecycle surface"),
"a lifecycle-only bridge must retain its valid compatibility migration: {lifecycle_message}"
);
}
#[test]
fn php_trait_bridge_exclusion_is_the_explicit_escape_hatch() {
let config = php_bridge_config(
r#"super_trait = "Plugin"
register_fn = "register_sample_plugin"
registry_getter = "sample_core::plugins::registry"
exclude_languages = ["php"]"#,
);
validate_resolved(&config).expect("an explicitly PHP-excluded bridge emits no Zend wrapper");
}
#[test]
fn php_unsupported_callbacks_preserve_a_safe_lifecycle_only_bridge() {
let config = php_bridge_config(
r#"super_trait = "Plugin"
register_fn = "register_sample_plugin"
unregister_fn = "unregister_sample_plugin"
clear_fn = "clear_sample_plugins"
registry_getter = "sample_core::plugins::registry"
exclude_languages = ["php:callbacks"]"#,
);
validate_resolved(&config).expect("PHP callback execution is explicitly disabled");
}
#[test]
fn php_unsupported_callbacks_reject_callback_attachment_shapes() {
let cases = [
(
"function parameter",
"function parameter",
r#"type_alias = "SamplePluginHandle"
param_name = "plugin"
bind_via = "function_param""#,
),
(
"options field",
"options field",
r#"type_alias = "SamplePluginHandle"
param_name = "plugin"
bind_via = "options_field"
options_type = "SampleOptions"
options_field = "plugin""#,
),
(
"visitor metadata",
"visitor callback",
r#"context_type = "NodeContext"
result_type = "VisitResult""#,
),
(
"stray options metadata",
"options field",
r#"options_type = "SampleOptions"
options_field = "plugin""#,
),
];
for (case, attachment_shape, attachment) in cases {
let config = php_bridge_config(&format!(
r#"{attachment}
exclude_languages = ["php:callbacks"]"#
));
let error = validate_resolved(&config).expect_err("callback attachment must remain fail-closed");
let message = error.to_string();
assert!(
message.contains("php:callbacks")
&& message.contains("lifecycle-only")
&& message.contains(attachment_shape),
"{case} must fail with a clear lifecycle-only diagnostic: {message}"
);
}
}
#[test]
fn non_php_trait_bridge_and_ordinary_php_crate_remain_valid() {
let non_php = resolve_first(
r#"
[workspace]
languages = ["python"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
[[crates.trait_bridges]]
trait_name = "SamplePlugin"
type_alias = "SamplePluginHandle"
param_name = "plugin"
"#,
);
validate_resolved(&non_php).expect("the safety gate is specific to PHP Zend values");
let ordinary_php = resolve_first(
r#"
[workspace]
languages = ["php"]
[[crates]]
name = "sample-core"
sources = ["src/lib.rs"]
"#,
);
validate_resolved(&ordinary_php).expect("ordinary PHP binding generation remains supported");
}
}