Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions crates/allium-parser/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4729,27 +4729,44 @@ pub fn collect_trigger_outputs(module: &Module) -> HashSet<String> {
}

/// 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).
pub fn collect_referenced_trigger_names(module: &Module) -> HashSet<String> {
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
Expand Down Expand Up @@ -8060,6 +8077,38 @@ 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"));
}

#[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");
}

// -- Emissions after the first ensures statement --

const MULTI_EMISSION_SPEC: &str = r#"
Expand Down
113 changes: 113 additions & 0 deletions crates/allium/tests/cross_module_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,119 @@ fn bad_alias_on_a_collection_is_still_diagnosed() {
);
}

// ===========================================================================
// 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::<Vec<_>>()
);
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}"
);
}

// ===========================================================================
// Emissions after the first ensures statement export to importers
// ===========================================================================
Expand Down