Iter 5b: Cross-Module-Typcheck mit qualifizierten Verweisen

Neue API check_workspace(&Workspace) -> Vec<Diagnostic>; check_module
hebt das Modul intern in einen Trivial-Workspace. Term::Var mit genau
einem Punkt im Namen ist ein qualifizierter Verweis <prefix>.<def>,
aufgelöst über Import-Map (alias-oder-modulname → echter Modulname).
Drei neue Diagnostic-Codes: unknown-module, unknown-import,
invalid-def-name. ail check lädt jetzt immer via load_workspace;
Loader-Fehler werden im JSON-Modus zu strukturierten Diagnostics
(module-not-found, module-cycle, …). DESIGN.md und JOURNAL.md
dokumentieren die Konvention. Hash-Stabilität: alle ir_snapshot_*-
Tests bitidentisch grün, kein neuer AST-Knoten.
This commit is contained in:
2026-05-07 11:31:57 +02:00
parent 3451b5bd15
commit b2878fe655
10 changed files with 601 additions and 67 deletions
+115 -22
View File
@@ -194,27 +194,23 @@ fn main() -> Result<()> {
}
}
Cmd::Check { path, json } => {
// Iter 5b: `ail check` lädt jetzt **immer** über
// `load_workspace` und prüft cross-module. Für Module ohne
// Imports verhält sich der Workspace-Loader äquivalent zu
// `load_module` plus Hash-Konsistenz-Check des Eintrittsfiles
// — damit ist der Pfad einheitlich.
if json {
// JSON-Modus: stdout enthält ausschließlich das Diagnostics-
// Array. Schema-Mismatch wird als strukturiertes Diagnostic
// emittiert (mit Code `schema-mismatch`); echte I/O-Fehler
// propagieren als fatal, weil sie keinen Modul-bezogenen
// Diagnostic-Kontext haben.
let diags = match ailang_core::load_module(&path) {
Ok(m) => ailang_check::check_module(&m),
Err(ailang_core::Error::SchemaMismatch { expected, got }) => {
vec![ailang_check::Diagnostic::error(
"schema-mismatch",
format!(
"schema mismatch: expected {expected:?}, got {got:?}"
),
)
.with_ctx(serde_json::json!({
"expected": expected,
"actual": got,
}))]
}
Err(e) => return Err(anyhow::anyhow!(e)),
// Array. Workspace-Lade-Fehler werden als strukturierte
// Diagnostics emittiert (Codes `module-not-found`,
// `module-cycle`, `module-name-mismatch`, `schema-mismatch`).
// Echte I/O-Fehler des Eintrittsfiles bleiben fatal.
let diags = match ailang_core::load_workspace(&path) {
Ok(ws) => ailang_check::check_workspace(&ws),
Err(e) => match workspace_error_to_diagnostic(&e) {
Some(d) => vec![d],
None => return Err(anyhow::anyhow!(e)),
},
};
println!("{}", serde_json::to_string(&diags)?);
if diags
@@ -224,9 +220,32 @@ fn main() -> Result<()> {
std::process::exit(1);
}
} else {
let m = ailang_core::load_module(&path)?;
let r = ailang_check::check(&m)?;
println!("ok ({} symbols)", r.symbols.len());
let ws = ailang_core::load_workspace(&path)?;
let diags = ailang_check::check_workspace(&ws);
if !diags.is_empty() {
for d in &diags {
eprintln!(
"{}: [{}] {}{}",
match d.severity {
ailang_check::Severity::Error => "error",
ailang_check::Severity::Warning => "warning",
},
d.code,
d.def
.as_ref()
.map(|n| format!("{n}: "))
.unwrap_or_default(),
d.message,
);
}
std::process::exit(1);
}
let total: usize = ws.modules.values().map(|m| m.defs.len()).sum();
println!(
"ok ({} symbols across {} modules)",
total,
ws.modules.len()
);
}
}
Cmd::EmitIr { path, out } => {
@@ -384,6 +403,80 @@ fn main() -> Result<()> {
Ok(())
}
/// Wandelt einen `WorkspaceLoadError` in ein passendes Diagnostic für den
/// JSON-Modus von `ail check`. Reine I/O-Fehler haben kein Modul-Diagnostic-
/// Äquivalent (sie sind nicht der Pipeline-Sache eines Konsumenten); für
/// die liefern wir `None` und lassen den Aufrufer fatal scheitern.
fn workspace_error_to_diagnostic(
e: &ailang_core::WorkspaceLoadError,
) -> Option<ailang_check::Diagnostic> {
use ailang_core::WorkspaceLoadError as W;
match e {
W::Io { .. } => None,
W::Schema { source, .. } => match source {
ailang_core::Error::SchemaMismatch { expected, got } => Some(
ailang_check::Diagnostic::error(
"schema-mismatch",
format!(
"schema mismatch: expected {expected:?}, got {got:?}"
),
)
.with_ctx(serde_json::json!({
"expected": expected,
"actual": got,
})),
),
_ => None,
},
W::ModuleNotFound { name, expected_path } => Some(
ailang_check::Diagnostic::error(
"module-not-found",
format!(
"module `{name}` not found (expected at {})",
expected_path.display()
),
)
.with_ctx(serde_json::json!({
"module": name,
"expected_path": expected_path.display().to_string(),
})),
),
W::ModuleNameMismatch {
name_in_file,
name_from_path,
} => Some(
ailang_check::Diagnostic::error(
"module-name-mismatch",
format!(
"module name mismatch: file says {name_in_file:?}, path implies {name_from_path:?}"
),
)
.with_ctx(serde_json::json!({
"name_in_file": name_in_file,
"name_from_path": name_from_path,
})),
),
W::Cycle { path } => Some(
ailang_check::Diagnostic::error(
"module-cycle",
format!("import cycle: {}", path.join(" -> ")),
)
.with_ctx(serde_json::json!({
"path": path,
})),
),
W::ModuleHashMismatch { name } => Some(
ailang_check::Diagnostic::error(
"module-hash-mismatch",
format!("module `{name}` loaded twice with differing content"),
)
.with_ctx(serde_json::json!({
"module": name,
})),
),
}
}
fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
match def {
+27
View File
@@ -229,6 +229,33 @@ fn workspace_lists_imported_modules() {
assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}");
}
/// Schützt Iter 5b: `ail check examples/ws_main.ail.json --json` muss den
/// Cross-Module-Aufruf `ws_lib.add` auflösen können. Erwartet: Exit 0,
/// stdout exakt `[]` (leeres Diagnostic-Array).
#[test]
fn check_workspace_resolves_import() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let entry = workspace.join("examples").join("ws_main.ail.json");
let output = Command::new(ail_bin())
.args(["check", entry.to_str().unwrap(), "--json"])
.output()
.expect("ail check --json failed to run");
let code = output.status.code().expect("process terminated by signal");
assert_eq!(
code,
0,
"expected exit 0; stderr: {}; stdout: {}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array");
}
/// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten.
/// `broken_unbound.ail.json` referenziert eine nicht-existente Variable;
/// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit
+8 -1
View File
@@ -10,7 +10,7 @@
//! Diagnostic gemeldet (single-shot). Mehrere Diagnostics pro Lauf sind ein
//! späteres Feature; das aktuelle Format erlaubt sie aber bereits.
//!
//! Stabile Codes (Stand Iteration 4):
//! Stabile Codes (Stand Iteration 5b):
//! - `schema-mismatch`
//! - `unknown-type`
//! - `unbound-var`
@@ -32,6 +32,13 @@
//! - `primitive-needs-wildcard`
//! - `duplicate-type`
//! - `duplicate-ctor`
//! - `unknown-module` — `ctx`: `{"module": "<prefix>"}` (Iter 5b)
//! - `unknown-import` — `ctx`: `{"module": "<m>", "name": "<def>"}` (Iter 5b)
//! - `invalid-def-name` — `ctx`: `{"name": "<n>", "reason": "contains-dot"}` (Iter 5b)
//! - `module-not-found` — Workspace-Loader (Iter 5b, im CLI-Pfad)
//! - `module-cycle` — Workspace-Loader (Iter 5b, im CLI-Pfad)
//! - `module-name-mismatch` — Workspace-Loader (Iter 5b, im CLI-Pfad)
//! - `module-hash-mismatch` — Workspace-Loader (Iter 5b, im CLI-Pfad)
use serde::Serialize;
+241 -42
View File
@@ -7,8 +7,9 @@
//! Eingebaute Operationen werden über [`Builtins`] aufgelöst.
use ailang_core::ast::*;
use ailang_core::Workspace;
use indexmap::IndexMap;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
pub mod builtins;
pub mod diagnostic;
@@ -95,6 +96,15 @@ pub enum CheckError {
#[error("nested constructor pattern not allowed in MVP: `{0}`")]
NestedCtorPatternNotAllowed(String),
#[error("unknown module prefix `{module}` in qualified reference")]
UnknownModule { module: String },
#[error("module `{module}` has no top-level def `{name}`")]
UnknownImport { module: String, name: String },
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
InvalidDefName { name: String },
}
type Result<T> = std::result::Result<T, CheckError>;
@@ -127,6 +137,9 @@ impl CheckError {
CheckError::DuplicateCtor { .. } => "duplicate-ctor",
CheckError::DuplicateDef(_) => "duplicate-def",
CheckError::NestedCtorPatternNotAllowed(_) => "nested-ctor-pattern-not-allowed",
CheckError::UnknownModule { .. } => "unknown-module",
CheckError::UnknownImport { .. } => "unknown-import",
CheckError::InvalidDefName { .. } => "invalid-def-name",
}
}
@@ -152,6 +165,15 @@ impl CheckError {
CheckError::NonExhaustive { missing, .. } => {
serde_json::json!({"missing": missing})
}
CheckError::UnknownModule { module } => {
serde_json::json!({"module": module})
}
CheckError::UnknownImport { module, name } => {
serde_json::json!({"module": module, "name": name})
}
CheckError::InvalidDefName { name } => {
serde_json::json!({"name": name, "reason": "contains-dot"})
}
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
@@ -192,11 +214,67 @@ impl CheckError {
/// Leerer Vec = grün. Im aktuellen Stand wird beim ersten Fehler abgebrochen,
/// daher enthält der Vec entweder 0 oder 1 Element. Mehrere Diagnostics pro
/// Lauf sind ein späteres Feature; das Format erlaubt sie bereits.
///
/// Rückwärtskompatibilität: ein nackter `&Module` wird intern in einen
/// Trivial-Workspace gehoben (`modules = {m.name: m}`, `entry = m.name`),
/// damit Tooling, das einzelne Module checkt, ohne `Workspace`-Bau auskommt.
/// Module mit Imports auf nicht im Trivial-Workspace vorhandene andere
/// Module werden hier zwangsläufig `unknown-module`-Fehler bei qualifizierten
/// Referenzen liefern — was korrekt ist.
pub fn check_module(m: &Module) -> Vec<Diagnostic> {
match check(m) {
Ok(_) => Vec::new(),
Err(e) => vec![e.to_diagnostic()],
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
};
check_workspace(&ws)
}
/// Top-Level-API für Cross-Module-Typcheck.
///
/// Iteriert über alle Module des Workspaces und prüft jedes mit Zugriff auf
/// die Top-Level-Symboltabellen aller anderen Module. Qualifizierte
/// Referenzen werden über die Import-Map des jeweiligen Moduls aufgelöst:
/// `Term::Var { name }` mit genau einem Punkt im Namen wird als
/// `<prefix>.<def>` interpretiert; `<prefix>` ist ein Import-Alias (oder
/// der Modulname, falls ohne Alias importiert).
///
/// Wie bei `check_module`: pro Lauf maximal **ein** Diagnostic
/// (single-shot). Multi-Diagnose ist späteres Feature.
pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
// Pass 1: pro Modul Top-Level-Symboltabelle aufbauen — ohne Bodies zu
// checken. Damit kann Modul A auf Defs aus Modul B zugreifen, auch
// wenn B in der BTreeMap später kommt. Doppelte Def-Namen und
// Punkt-im-Def-Namen werden hier sofort gemeldet, weil ohne saubere
// Symboltabellen alle weiteren Diagnostics unzuverlässig wären.
let module_globals = match build_module_globals(ws) {
Ok(g) => g,
Err(e) => return vec![e.to_diagnostic()],
};
// Pass 2: pro Modul body-checken. `check_in_workspace` baut den Env
// mit zusätzlichen Cross-Module-Globals und einer Import-Map auf.
// Iterationsreihenfolge: erst das Eintrittsmodul, dann der Rest in
// BTreeMap-Order. Damit ist die erste gemeldete Diagnostik bei
// Workspaces deterministisch und nahe am Entry.
let mut order: Vec<&String> = Vec::new();
if ws.modules.contains_key(&ws.entry) {
order.push(&ws.entry);
}
for name in ws.modules.keys() {
if name != &ws.entry {
order.push(name);
}
}
for name in order {
let m = &ws.modules[name];
if let Err(e) = check_in_workspace(m, ws, &module_globals) {
return vec![e.to_diagnostic()];
}
}
Vec::new()
}
/// Ergebnis der Typprüfung eines Moduls: Mapping vom Symbolnamen zum
@@ -207,22 +285,103 @@ pub struct CheckedModule {
}
pub fn check(m: &Module) -> Result<CheckedModule> {
// Trivial-Workspace: das Modul allein, ohne Cross-Module-Auflösung.
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
};
let module_globals = build_module_globals(&ws)?;
check_in_workspace(m, &ws, &module_globals)?;
// Symbole für die Rückgabe sammeln (bestehende Semantik).
let mut symbols = IndexMap::new();
for def in &m.defs {
let h = ailang_core::hash::def_hash(def);
let ty = match def {
Def::Fn(f) => f.ty.clone(),
Def::Const(c) => c.ty.clone(),
Def::Type(_) => Type::Con {
name: def.name().to_string(),
},
};
symbols.insert(def.name().to_string(), (ty, h));
}
Ok(CheckedModule { symbols })
}
/// Baut pro Modul die Top-Level-Symboltabelle (für Cross-Module-Lookup),
/// ohne Bodies zu prüfen. Dupes und Punkt-im-Def-Namen werden hier sofort
/// als Fehler gemeldet — sie würden alle weiteren Diagnostics verfälschen.
fn build_module_globals(
ws: &Workspace,
) -> Result<BTreeMap<String, IndexMap<String, Type>>> {
let mut out: BTreeMap<String, IndexMap<String, Type>> = BTreeMap::new();
for (mname, m) in &ws.modules {
let mut globals = IndexMap::new();
for def in &m.defs {
let def_name = def.name();
if def_name.contains('.') {
return Err(CheckError::Def(
def_name.to_string(),
Box::new(CheckError::InvalidDefName {
name: def_name.to_string(),
}),
));
}
if globals.contains_key(def_name) {
return Err(CheckError::Def(
def_name.to_string(),
Box::new(CheckError::DuplicateDef(def_name.to_string())),
));
}
let ty = match def {
Def::Fn(f) => f.ty.clone(),
Def::Const(c) => c.ty.clone(),
Def::Type(_) => Type::Con {
name: def_name.to_string(),
},
};
globals.insert(def_name.to_string(), ty);
}
out.insert(mname.clone(), globals);
}
Ok(out)
}
/// Prüft die Bodies eines einzelnen Moduls im Kontext des Workspaces.
/// Annahme: `module_globals` enthält bereits für **alle** Module des
/// Workspaces (inklusive `m`) die Top-Level-Symboltabellen — gebaut von
/// `build_module_globals`.
fn check_in_workspace(
m: &Module,
ws: &Workspace,
module_globals: &BTreeMap<String, IndexMap<String, Type>>,
) -> Result<()> {
let mut env = Env::new();
builtins::install(&mut env);
// Pass 1a: alle Type-Defs registrieren.
// Type-Defs registrieren (lokal pro Modul; ADT-Cross-Module-Sharing ist
// explizit nicht Teil von 5b).
for def in &m.defs {
if let Def::Type(td) = def {
if env.types.contains_key(&td.name) {
return Err(CheckError::DuplicateType(td.name.clone()));
return Err(CheckError::Def(
td.name.clone(),
Box::new(CheckError::DuplicateType(td.name.clone())),
));
}
for c in &td.ctors {
if let Some(prev) = env.ctor_index.get(&c.name) {
return Err(CheckError::DuplicateCtor {
ctor: c.name.clone(),
a: prev.type_name.clone(),
b: td.name.clone(),
});
return Err(CheckError::Def(
td.name.clone(),
Box::new(CheckError::DuplicateCtor {
ctor: c.name.clone(),
a: prev.type_name.clone(),
b: td.name.clone(),
}),
));
}
env.ctor_index.insert(
c.name.clone(),
@@ -235,43 +394,35 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
}
}
// Pass 1b: alle Top-Level-Werte-Symbole registrieren. Doppelte Namen
// (zwischen fn/const) sind unzulässig — das schützt davor, dass sich
// zwei Defs einen Namensraum teilen.
for def in &m.defs {
match def {
Def::Fn(f) => {
if env.globals.contains_key(&f.name) {
return Err(CheckError::DuplicateDef(f.name.clone()));
}
env.globals.insert(f.name.clone(), f.ty.clone());
}
Def::Const(c) => {
if env.globals.contains_key(&c.name) {
return Err(CheckError::DuplicateDef(c.name.clone()));
}
env.globals.insert(c.name.clone(), c.ty.clone());
}
Def::Type(_) => {}
// Lokale Globals aus der vorgängig gebauten Tabelle übernehmen.
if let Some(g) = module_globals.get(&m.name) {
for (n, t) in g {
env.globals.insert(n.clone(), t.clone());
}
}
// Pass 2: jede Def prüfen.
let mut symbols = IndexMap::new();
// Import-Map aufbauen: Alias (oder Modulname, wenn ohne Alias) →
// Modulname. Konflikte sind im MVP unzulässig: dieselbe `as`-Klausel
// zweimal wäre auffällig und sollte als doppelter Symbolname auffallen
// — aktuell „last wins", weil Iter 5b kein eigenes Diagnostic dafür
// einführt; falls künftig benötigt → `ambiguous-import`-Code.
let mut import_map: BTreeMap<String, String> = BTreeMap::new();
for imp in &m.imports {
let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
import_map.insert(key, imp.module.clone());
}
env.imports = import_map;
env.module_globals = module_globals.clone();
env.current_module = m.name.clone();
// Workspace ist im Env nicht direkt nötig; Cross-Module-Lookup nutzt
// ausschließlich `module_globals`. Aber wir behalten ws-Referenz im
// Kommentar als Erinnerung, falls künftig ADT-Cross-Module dazukommt.
let _ = ws;
for def in &m.defs {
check_def(def, &env).map_err(|e| CheckError::Def(def.name().to_string(), Box::new(e)))?;
let h = ailang_core::hash::def_hash(def);
let ty = match def {
Def::Fn(f) => f.ty.clone(),
Def::Const(c) => c.ty.clone(),
Def::Type(_) => Type::Con {
name: def.name().to_string(),
},
};
symbols.insert(def.name().to_string(), (ty, h));
}
Ok(CheckedModule { symbols })
Ok(())
}
fn check_def(def: &Def, env: &Env) -> Result<()> {
@@ -386,12 +537,49 @@ fn synth(
Literal::Unit => Type::unit(),
}),
Term::Var { name } => {
// 1) Locals haben höchste Priorität — sie können sogar einen
// qualifizierten Punkt-Namen schatten, falls jemand einen
// Buchstaben-mit-Punkt-Param baut. Das ist im MVP nicht
// wirklich erreichbar, aber harmlos.
if let Some(t) = locals.get(name) {
return Ok(t.clone());
}
// 2) Lokale Globals.
if let Some(t) = env.globals.get(name) {
return Ok(t.clone());
}
// 3) Genau ein Punkt → qualifizierter Cross-Module-Verweis.
// Mehr als ein Punkt ist im aktuellen MVP nicht definiert
// und fällt unten als `unbound-var` durch.
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
let target_module = match env.imports.get(prefix) {
Some(m) => m.clone(),
None => {
// Selbst-Referenz `<self>.def` ohne Import-Eintrag:
// erlauben wir bewusst nicht — Konvention ist, dass
// qualifizierte Verweise nur über Imports gehen.
// Damit bleibt die Bedeutung von `name` lokal stabil.
return Err(CheckError::UnknownModule {
module: prefix.to_string(),
});
}
};
let g = env.module_globals.get(&target_module).ok_or_else(|| {
// Import-Map zeigt auf ein nicht im Workspace geladenes
// Modul. Das wäre eigentlich schon vom Workspace-Loader
// abgefangen worden; hier defensiv als unknown-module.
CheckError::UnknownModule {
module: target_module.clone(),
}
})?;
return g.get(suffix).cloned().ok_or_else(|| {
CheckError::UnknownImport {
module: target_module,
name: suffix.to_string(),
}
});
}
Err(CheckError::UnknownIdent(name.clone()))
}
Term::App { callee, args } => {
@@ -675,6 +863,17 @@ pub struct Env {
pub types: IndexMap<String, TypeDef>,
/// Inverser Index: ctor-name -> Verweis auf die zugehörige ADT.
pub ctor_index: IndexMap<String, CtorRef>,
/// Import-Map: alias-oder-modulname → tatsächlicher Modulname.
/// Genutzt, wenn `Term::Var { name }` einen Punkt enthält
/// (qualifizierter Cross-Module-Verweis).
pub imports: BTreeMap<String, String>,
/// Pro Modul des Workspaces dessen Top-Level-Symboltabelle.
/// `check_in_workspace` befüllt das aus `build_module_globals`.
pub module_globals: BTreeMap<String, IndexMap<String, Type>>,
/// Name des aktuell gecheckten Moduls. Genutzt, um beim Var-Lookup
/// Selbst-Verweise (Modulname == eigener Name) als lokale Globals zu
/// behandeln, ohne den `imports`-Kanal anzufassen.
pub current_module: String,
}
#[derive(Debug, Clone)]
+101
View File
@@ -0,0 +1,101 @@
//! Integrationstests für `check_workspace` (Iter 5b).
//!
//! Diese Tests fahren über die kanonischen `examples/ws_*.ail.json`-Files;
//! der Loader und der Checker zusammen sind die Pipeline, die `ail check`
//! im JSON-Modus auf einen Workspace wirft.
use ailang_check::{check_workspace, Severity};
use ailang_core::load_workspace;
use std::path::Path;
fn examples_dir() -> std::path::PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
Path::new(manifest).parent().unwrap().parent().unwrap().join("examples")
}
#[test]
fn happy_path_resolves_qualified_import() {
// ws_main importiert ws_lib und ruft `ws_lib.add` auf — vollständig
// typisiert. Erwartet: keine Diagnostics.
let entry = examples_dir().join("ws_main.ail.json");
let ws = load_workspace(&entry).expect("load ws_main");
let diags = check_workspace(&ws);
assert!(
diags.is_empty(),
"expected no diagnostics; got: {}",
serde_json::to_string_pretty(&diags).unwrap()
);
}
#[test]
fn unknown_import_is_reported() {
// ws_broken referenziert `ws_lib.bogus` — Modul ist da, Def nicht.
let entry = examples_dir().join("ws_broken.ail.json");
let ws = load_workspace(&entry).expect("load ws_broken");
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert!(matches!(diags[0].severity, Severity::Error));
assert_eq!(diags[0].code, "unknown-import");
// Kontext muss strukturiert auf Modul + Defname zeigen.
assert_eq!(
diags[0].ctx.get("module").and_then(|v| v.as_str()),
Some("ws_lib")
);
assert_eq!(
diags[0].ctx.get("name").and_then(|v| v.as_str()),
Some("bogus")
);
}
#[test]
fn unknown_module_prefix_is_reported() {
// ws_unknown_module hat keine Imports, referenziert aber `nope.x`.
let entry = examples_dir().join("ws_unknown_module.ail.json");
let ws = load_workspace(&entry).expect("load ws_unknown_module");
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert!(matches!(diags[0].severity, Severity::Error));
assert_eq!(diags[0].code, "unknown-module");
assert_eq!(
diags[0].ctx.get("module").and_then(|v| v.as_str()),
Some("nope")
);
}
#[test]
fn invalid_def_name_with_dot_is_reported() {
// Synthetic: ein Modul mit einer Def, deren Name einen Punkt enthält.
// Wir konstruieren das als Module direkt und füttern es in einen
// Trivial-Workspace, weil die kanonische Konvention das gar nicht
// erst auf Disk durchlassen sollte.
use ailang_core::ast::*;
use std::collections::BTreeMap;
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Const(ConstDef {
name: "weird.name".into(),
ty: Type::int(),
value: Term::Lit {
lit: Literal::Int { value: 0 },
},
doc: None,
})],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
};
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 1, "got: {:?}", diags);
assert_eq!(diags[0].code, "invalid-def-name");
assert_eq!(
diags[0].ctx.get("reason").and_then(|v| v.as_str()),
Some("contains-dot")
);
}
+19
View File
@@ -100,6 +100,25 @@ Begründung:
Trade-off: keine Inline-Optimierungen über die LLVM-API. Wir setzen auf
`clang -O2` als Standard-Pipeline.
## Konvention: Qualifizierte Cross-Module-Verweise (Iter 5b)
Cross-Module-Aufrufe nutzen **keinen** neuen AST-Knoten. Stattdessen ist ein
`Term::Var { name }` mit genau einem Punkt im Namen ein qualifizierter
Verweis: `<prefix>.<def>`.
- `<prefix>` ist ein Import-Alias (`import { module: "X", as: "<prefix>" }`)
oder, falls ohne Alias importiert, der Modulname selbst.
- `<def>` ist der Name einer Top-Level-Definition im Zielmodul.
- Def-Namen DÜRFEN keinen Punkt enthalten — der Typchecker meldet
`invalid-def-name` mit `ctx: { "reason": "contains-dot" }`.
- Der Workspace-Loader (Iter 5a) findet alle erreichbaren Module; der
Typchecker (Iter 5b, `check_workspace`) löst Punkt-Namen über die
Import-Map auf. Diagnostic-Codes: `unknown-module` (Prefix nicht
importiert), `unknown-import` (Modul gefunden, Def nicht).
Hash-Stabilität: kein neuer AST-Knoten, keine umbenannten Felder — alle
bisherigen Modul-Hashes bleiben bitidentisch.
## Datenmodell (MVP)
### Module
+29
View File
@@ -220,3 +220,32 @@ Sub-Schritte:
Während Iter 5 gleich beim Bau **`source_filename` pfad-unabhängig
halten** (nur Modulname, kein Verzeichnis-Prefix), sonst kippen die
IR-Snapshots.
## 2026-05-07 — Iter 5b fertig: Cross-Module-Typcheck
- `check_workspace(&Workspace) -> Vec<Diagnostic>` als Top-Level-API.
`check_module` bleibt erhalten und hebt das Modul intern in einen
Trivial-Workspace.
- Konvention für qualifizierte Verweise (in DESIGN.md festgehalten):
`Term::Var { name }` mit genau einem Punkt = `<prefix>.<def>`. Prefix
ist Import-Alias oder Modulname. Kein neuer AST-Knoten, keine
umbenannten Felder ⇒ Hashes bleiben stabil; alle `ir_snapshot_*`
weiterhin grün.
- Drei neue Diagnostic-Codes: `unknown-module`,
`unknown-import`, `invalid-def-name` (mit `ctx.reason: "contains-dot"`).
- CLI: `ail check <entry>` lädt jetzt **immer** über `load_workspace`.
Workspace-Lade-Fehler werden im JSON-Modus zu strukturierten
Diagnostics mit Codes `module-not-found`, `module-cycle`,
`module-name-mismatch`, `module-hash-mismatch`, `schema-mismatch`.
`ail build` und `ail emit-ir` bleiben pro Einzelmodul (Codegen
cross-module ist 5c).
- Beispiele: `ws_main.ail.json` ruft jetzt `ws_lib.add` auf
(beobachtbar). Neu: `ws_broken.ail.json` (`unknown-import`),
`ws_unknown_module.ail.json` (`unknown-module`).
- Tests: 37 grün (vorher 32). 4 neue Workspace-Integrationstests in
`crates/ailang-check/tests/workspace.rs`, ein neuer e2e-Test
`check_workspace_resolves_import`.
- Schulden: Single-Shot-Diagnostics weiterhin (Multi-Diagnose nach 5c).
Punkt-Konvention deckt nur einen Punkt ab — verschachtelte Modulpfade
(`a.b.c`) gibt es nicht; das wäre erst mit Hierarchie-Modulen ein
Thema und fällt aktuell als `unbound-var` durch.
+29
View File
@@ -0,0 +1,29 @@
{
"schema": "ailang/v0",
"name": "ws_broken",
"imports": [
{ "module": "ws_lib" }
],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"doc": "Iter 5b negative fixture: ruft ws_lib.bogus auf — Modul existiert, Def nicht. Triggert `unknown-import`.",
"body": {
"t": "app",
"fn": { "t": "var", "name": "ws_lib.bogus" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{ "t": "lit", "lit": { "kind": "int", "value": 2 } }
]
}
}
]
}
+9 -2
View File
@@ -15,12 +15,19 @@
"effects": ["IO"]
},
"params": [],
"doc": "Iter 5a fixture: Eintrittsmodul, importiert ws_lib (noch ungenutzt — Cross-Module-Aufruf folgt in 5b/5c).",
"doc": "Iter 5b fixture: ruft ws_lib.add auf, druckt das Ergebnis. So ist Cross-Module-Typcheck am Beispiel beobachtbar.",
"body": {
"t": "do",
"op": "io/print_int",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
{
"t": "app",
"fn": { "t": "var", "name": "ws_lib.add" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 2 } },
{ "t": "lit", "lit": { "kind": "int", "value": 3 } }
]
}
]
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schema": "ailang/v0",
"name": "ws_unknown_module",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"doc": "Iter 5b negative fixture: kein Import, aber qualifizierter Verweis `nope.x`. Triggert `unknown-module`.",
"body": {
"t": "var",
"name": "nope.x"
}
}
]
}