From 986f38bb8744ead7bdf5f55a114b491d09e6c510 Mon Sep 17 00:00:00 2001 From: Eric Dvorsak Date: Thu, 13 Aug 2026 10:01:00 +0200 Subject: [PATCH 1/2] Offer config blocks and deferred declarations to importers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offered-names set a qualified reference alias/Name is validated against held declared types and referenced triggers only. A module's config block and its deferred declarations were absent, so two documented reference forms drew false allium.reference.unknownName warnings: - alias/config.param — the language reference's 'Config parameter references' section documents exactly this form, and checker rule 46 requires it to resolve; even the documented config-default position warned. - alias/DeferredName — a deferred declaration the module visibly carries. The diagnostic also claimed the module 'does not define' a config block it plainly defines. collect_referenced_trigger_names now offers 'config' when the module declares a config block, and each deferred declaration's root name (deferred Foo and deferred Foo.bar both offer Foo). A deferred declared against another module's alias (deferred other/Foo) stays a foreign reference, not a local offering. Field-level validation of config references (rules 46/47: does page_size exist, do the types match) remains open — this fix stops the false positive on the block reference itself. Co-Authored-By: Claude Fable 5 --- crates/allium-parser/src/analysis.rs | 60 ++++++++-- crates/allium/tests/cross_module_lifecycle.rs | 113 ++++++++++++++++++ 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/crates/allium-parser/src/analysis.rs b/crates/allium-parser/src/analysis.rs index a1f1210..089e84b 100644 --- a/crates/allium-parser/src/analysis.rs +++ b/crates/allium-parser/src/analysis.rs @@ -4710,8 +4710,11 @@ pub fn collect_trigger_outputs(module: &Module) -> HashSet { } /// Collect every name a module *offers* to importers: declared type names -/// (`collect_declared_names`), plus every trigger name it references — provided, -/// emitted (`collect_trigger_outputs`), or listened for in `when:` clauses. +/// (`collect_declared_names`), every trigger name it references — provided, +/// emitted (`collect_trigger_outputs`), or listened for in `when:` clauses — +/// its `deferred` declarations, and `config` when it declares a config block +/// (the language reference's "Config parameter references" makes +/// `alias/config.param` a documented reference form, checker rule 46). /// Used by multi-file checking to validate a qualified reference `alias/Name` /// against the aliased module — a name it never mentions is a resolution error /// at the reference (#72, and the name-existence audit). @@ -4719,18 +4722,32 @@ pub fn collect_referenced_trigger_names(module: &Module) -> HashSet { let mut names = collect_trigger_outputs(module); names.extend(collect_declared_names(module)); for d in &module.declarations { - let Decl::Block(b) = d else { continue }; - if b.kind != BlockKind::Rule { - continue; - } - for item in &b.items { - if let BlockItemKind::Clause { keyword, value } = &item.kind { - if keyword == "when" { - for tref in extract_trigger_refs(value) { - names.insert(tref.name.to_string()); + match d { + // A deferred declaration offers its root name (`deferred Foo` and + // `deferred Foo.bar` both offer `Foo`). A deferred declared + // against another module's alias (`deferred other/Foo`) names a + // construct that module owns, not a local offering — + // extract_leading_ident returns None for qualified paths. + Decl::Deferred(def) => { + if let Some(id) = extract_leading_ident(&def.path) { + names.insert(id.name.clone()); + } + } + Decl::Block(b) if b.kind == BlockKind::Config => { + names.insert("config".to_string()); + } + Decl::Block(b) if b.kind == BlockKind::Rule => { + for item in &b.items { + if let BlockItemKind::Clause { keyword, value } = &item.kind { + if keyword == "when" { + for tref in extract_trigger_refs(value) { + names.insert(tref.name.to_string()); + } + } } } } + _ => {} } } names @@ -7937,6 +7954,27 @@ surface AccountManagement { ); } + // -- Names offered to importers -- + + #[test] + fn deferred_declarations_and_config_are_offered_to_importers() { + let input = "-- allium: 3\nconfig {\n page_size: Integer = 25\n}\n\ndeferred ExternalHelper\ndeferred Matching.suggest\n"; + let result = parse(input); + let names = collect_referenced_trigger_names(&result.module); + assert!(names.contains("config"), "a declared config block is referenceable as alias/config"); + assert!(names.contains("ExternalHelper"), "a deferred declaration is referenceable by name"); + assert!(names.contains("Matching"), "a dotted deferred offers its root name"); + } + + #[test] + fn a_module_without_config_or_deferred_offers_neither() { + let input = "-- allium: 3\nentity Thing {\n id: Integer\n}\n"; + let result = parse(input); + let names = collect_referenced_trigger_names(&result.module); + assert!(!names.contains("config"), "no config block, no config offering"); + assert!(!names.contains("ExternalHelper")); + } + // -- Unused entities -- #[test] diff --git a/crates/allium/tests/cross_module_lifecycle.rs b/crates/allium/tests/cross_module_lifecycle.rs index a9a525f..6593952 100644 --- a/crates/allium/tests/cross_module_lifecycle.rs +++ b/crates/allium/tests/cross_module_lifecycle.rs @@ -1110,3 +1110,116 @@ fn bad_alias_on_a_collection_is_still_diagnosed() { "an unknown alias on a collection must still be diagnosed.\n{stdout}" ); } + +// =========================================================================== +// config parameters and deferred declarations resolve through an alias +// =========================================================================== + +const CONFIG_DEFERRED_PROVIDER: &str = r#"-- allium: 3 +config { + page_size: Integer = 25 +} + +deferred ExternalHelper -- see: elsewhere.allium +"#; + +// The config-default reference is the exact form the language reference +// documents ("Config parameter references"): a local parameter defaulting to +// an imported module's config value. +const CONFIG_DEFERRED_CONSUMER: &str = r#"-- allium: 3 +use "./provider.allium" as p + +config { + local_page_size: Integer = p/config.page_size +} + +surface Api { + provides: + Go(size) +} + +rule ReadsConfigInRule { + when: Go(size) + + requires: size > p/config.page_size + + ensures: Accepted(size: size) +} + +rule UsesDeferred { + when: Go(size) + + requires: p/ExternalHelper(size) + + ensures: Delegated(size: size) +} +"#; + +#[test] +fn imported_config_and_deferred_references_resolve() { + // `alias/config.param` is documented ("Config parameter references", + // checker rule 46) and `alias/DeferredName` names a declaration the + // module visibly carries; neither may warn unknownName. + let dir = TempDir::new("config-deferred"); + dir.write("provider.allium", CONFIG_DEFERRED_PROVIDER); + dir.write("consumer.allium", CONFIG_DEFERRED_CONSUMER); + + let (ok, stdout) = run("check", &[dir.path().to_str().unwrap()]); + let false_positives: Vec<_> = parse_diagnostics(&stdout) + .into_iter() + .filter(|d| d.code == "allium.reference.unknownName") + .collect(); + assert!( + false_positives.is_empty(), + "config and deferred references through an alias must resolve.\nGot: {:?}", + false_positives.iter().map(|d| &d.message).collect::>() + ); + assert!(ok, "check on the pair should exit 0.\n{stdout}"); +} + +#[test] +fn a_deferred_name_the_provider_never_declares_still_warns() { + // Don't overcorrect: a name that is neither declared, referenced, + // deferred nor `config` still fails membership. + let dir = TempDir::new("config-deferred-guard"); + dir.write("provider.allium", CONFIG_DEFERRED_PROVIDER); + dir.write( + "consumer.allium", + "-- allium: 3\nuse \"./provider.allium\" as p\n\n\ + surface Api {\n provides:\n Go(size)\n}\n\n\ + rule UsesGhost {\n when: Go(size)\n\n requires: p/NoSuchDeferred(size)\n\n ensures: Done(size: size)\n}\n", + ); + + let (_ok, stdout) = run("check", &[dir.path().to_str().unwrap()]); + assert!( + parse_diagnostics(&stdout) + .iter() + .any(|d| d.code == "allium.reference.unknownName" && d.message.contains("NoSuchDeferred")), + "a name the provider never mentions must still warn.\n{stdout}" + ); +} + +#[test] +fn config_reference_against_a_module_without_config_still_warns() { + // Don't overcorrect: `alias/config` resolves only when the aliased module + // actually declares a config block. + let dir = TempDir::new("config-absent-guard"); + dir.write( + "provider.allium", + "-- allium: 3\nentity Thing {\n id: Integer\n}\n", + ); + dir.write( + "consumer.allium", + "-- allium: 3\nuse \"./provider.allium\" as p\n\n\ + surface Api {\n provides:\n Go(size)\n}\n\n\ + rule ReadsMissingConfig {\n when: Go(size)\n\n requires: size > p/config.page_size\n\n ensures: Done(size: size)\n}\n", + ); + + let (_ok, stdout) = run("check", &[dir.path().to_str().unwrap()]); + assert!( + parse_diagnostics(&stdout) + .iter() + .any(|d| d.code == "allium.reference.unknownName" && d.message.contains("config")), + "referencing config on a module with no config block must still warn.\n{stdout}" + ); +} From 19d08ec2f1ccc506fbcb73d84f66e4b6296e7832 Mon Sep 17 00:00:00 2001 From: Eric Dvorsak Date: Thu, 13 Aug 2026 10:28:37 +0200 Subject: [PATCH 2/2] Pin that a deferred declared against another module's alias is not offered deferred other/Foreign records that 'other' owns the construct; the declaring module must offer neither the name nor the alias. The behaviour was already implemented (extract_leading_ident returns None for qualified paths) but had no test holding it in place. Co-Authored-By: Claude Fable 5 --- crates/allium-parser/src/analysis.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/allium-parser/src/analysis.rs b/crates/allium-parser/src/analysis.rs index 089e84b..6536012 100644 --- a/crates/allium-parser/src/analysis.rs +++ b/crates/allium-parser/src/analysis.rs @@ -7975,6 +7975,17 @@ surface AccountManagement { assert!(!names.contains("ExternalHelper")); } + #[test] + fn a_deferred_declared_against_another_modules_alias_is_not_offered() { + // `deferred other/Foreign` records that `other` owns the construct; + // the declaring module offers neither the name nor the alias. + let input = "-- allium: 3\nuse \"./other.allium\" as other\n\ndeferred other/Foreign\n"; + let result = parse(input); + let names = collect_referenced_trigger_names(&result.module); + assert!(!names.contains("Foreign"), "a foreign deferred is not a local offering"); + assert!(!names.contains("other"), "the alias itself is not an offering"); + } + // -- Unused entities -- #[test]