Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
//! 已收集参数直接交给解析器,保持子命令、校验与透传行为。
use super::parse;
#[test]
fn supplied_arguments_preserve_owned_command_and_forwarded_values() {
let arguments = ["run", "project", "--", "--tenant", "7", "demo.value", "{}"].map(String::from);
let command = arguments[0].as_ptr();
let parsed = parse(arguments.into_iter()).unwrap().unwrap();
assert_eq!(parsed.command.as_ptr(), command);
assert_eq!(parsed.source, std::path::Path::new("project"));
assert_eq!(parsed.arguments, ["--tenant", "7", "demo.value", "{}"]);
}
#[test]
fn supplied_arguments_keep_help_tenant_and_build_validation() {
for arguments in [vec![], vec!["--help"], vec!["--version"]] {
assert!(
parse(arguments.into_iter().map(String::from))
.unwrap()
.is_none()
);
}
let tenant = parse(
["tenant", "migrate", "project", "7"]
.map(String::from)
.into_iter(),
)
.unwrap()
.unwrap();
assert_eq!(tenant.command, "run");
assert_eq!(
tenant.arguments,
["--tenant", "7", "dever.tenant.migrate", "null"]
);
for arguments in [
vec!["build", "source"],
vec!["build", "source", "--output", "a", "--output", "b"],
vec!["check", "source", "--output", "a"],
vec!["run", "source", "--target", "linux-x86_64"],
vec!["run", "source", "--entry"],
vec!["unknown"],
] {
assert!(parse(arguments.into_iter().map(String::from)).is_err());
}
}
+194
View File
@@ -0,0 +1,194 @@
use dever::compiler::check;
use dever::syntax::source::Source;
#[path = "compiler/application/mod.rs"]
mod application;
#[path = "compiler/auth/mod.rs"]
mod auth;
#[path = "compiler/database/mod.rs"]
mod database;
#[path = "compiler/job/mod.rs"]
mod job;
#[path = "compiler/api.rs"]
mod api;
#[path = "compiler/bytes.rs"]
mod bytes;
#[path = "compiler/calendar.rs"]
mod calendar;
#[path = "compiler/choice.rs"]
mod choice;
#[path = "compiler/collection.rs"]
mod collection;
#[path = "compiler/crypto.rs"]
mod crypto;
#[path = "compiler/failure.rs"]
mod failure;
#[path = "compiler/format.rs"]
mod format;
#[path = "compiler/handler.rs"]
mod handler;
#[path = "compiler/http.rs"]
mod http;
#[path = "compiler/id.rs"]
mod id;
#[path = "compiler/io/mod.rs"]
mod io;
#[path = "compiler/json.rs"]
mod json;
#[path = "compiler/net.rs"]
mod net;
#[path = "compiler/numeric/mod.rs"]
mod numeric;
#[path = "compiler/package.rs"]
mod package;
#[path = "compiler/port/mod.rs"]
mod port;
#[path = "compiler/standard.rs"]
mod standard;
#[path = "compiler/storage.rs"]
mod storage;
#[path = "compiler/task/mod.rs"]
mod task;
#[path = "compiler/tenant.rs"]
mod tenant;
#[path = "compiler/upload.rs"]
mod upload;
#[path = "compiler/usage.rs"]
mod usage;
#[path = "compiler/uuid.rs"]
mod uuid;
fn checked(
text: &str,
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
let source = Source::new(0, "case.dever", text.into());
check::check(vec![dever::syntax::parse(&source)?], &["main".into()])
}
#[test]
fn rejects_overlap_gaps_and_recursion() {
assert_eq!(
checked("pick(value: Int < 0) (answer: Int) { answer = 1 }")
.unwrap_err()
.code,
"C009"
);
assert_eq!(checked("pick(value: Int <= 0) (answer: Int) { answer = 1 }\npick(value: Int >= 0) (answer: Int) { answer = 2 }").unwrap_err().code, "C009");
assert_eq!(
checked("cycle() (answer: Int) { answer = cycle() }")
.unwrap_err()
.code,
"C007"
);
assert!(checked("pick(value: Int < 0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 2 }").is_ok());
}
#[test]
fn rejects_uninitialized_outputs_and_overflow() {
assert!(checked("main() (answer: Int) { value = answer }").is_err());
assert!(checked("main() (answer: Int) {}").is_err());
assert!(checked("main() (answer: Int) { answer = 9223372036854775807 + 1 }").is_err());
assert!(checked("main() (answer: Int) { answer = -9223372036854775808 }").is_ok());
assert!(checked("main() (answer: Int) { answer = 9223372036854775806 + (1 + 1) }").is_err());
assert!(checked("main() (answer: Decimal) { answer = 1.0 / (2.0 - 2.0) }").is_err());
}
#[test]
fn preserves_record_and_list_types() {
assert!(checked("type Row { name: Text\n count: Int }\nmain() (answer: Row) { answer = Row { name = \"雪\"\n count = sum([1, 2, 3]) } }").is_ok());
assert!(
checked("type Row { count: Int }\nmain() (answer: Row) { answer = Row { wrong = 1 } }")
.is_err()
);
assert!(checked("main() (answer: List<Int>) { answer = [1, true] }").is_err());
}
#[test]
fn retains_original_naming_constraints() {
assert_eq!(checked("read_value() () {}").unwrap_err().code, "C010");
assert_eq!(checked("Wrong() () {}").unwrap_err().code, "C010");
assert_eq!(checked("type bad { count: Int }").unwrap_err().code, "C010");
assert_eq!(checked("main() () { Bad = 1 }").unwrap_err().code, "C010");
assert!(checked("type HttpClient { user_id: Int }\nreadValue(input_value: Int) (output_value: Int) { output_value = input_value }").is_ok());
}
#[test]
#[ignore = "explicit compiler cost probe: fixed 15 rounds, run without concurrent CPU work"]
fn compiler_reduction_costs() {
use dever::compiler::project::Project;
use std::{hint::black_box, path::Path, time::Instant};
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures");
for (label, count) in [("medium", 32), ("large", 512)] {
let mut text = "copyText(value: Text) (answer: Text) { answer = value }\n".to_owned();
for index in 0..count {
let mut body =
format!("case{index}(value: Text) (answer: Text) {{\nanswer = copyText(value)\n");
for _ in 0..15 {
body.push_str("answer = copyText(answer)\n");
}
body.push_str("}\n");
text.push_str(&body);
}
let source = Source::new(0, "cost.dever", text);
let mut project = Project::load(&fixtures.join("core.dever")).unwrap();
project.sources[0] = Source::new(0, project.sources[0].path.clone(), source.text.clone());
let packages = vec!["main".to_owned()];
let expected_project_functions = project.check().unwrap().functions.len();
assert!(expected_project_functions > count);
println!(
"audit_compiler input={label} functions={} calls={} bytes={} project_bytes={} rounds=15 profile=test",
count + 1,
count * 16,
source.text.len(),
project.sources[0].text.len()
);
for round in 1..=15 {
let started = Instant::now();
let module = dever::syntax::parse(black_box(&source)).unwrap();
let elapsed = started.elapsed().as_nanos();
assert_eq!(module.declarations.len(), count + 1);
println!("audit_compiler case={label} phase=parse round={round} ns={elapsed}");
let started = Instant::now();
let program = check::check(vec![module], black_box(&packages)).unwrap();
let elapsed = started.elapsed().as_nanos();
assert_eq!(program.functions.len(), count + 1);
black_box(&program);
println!("audit_compiler case={label} phase=check round={round} ns={elapsed}");
drop(program);
let started = Instant::now();
let program = black_box(&project).check().unwrap();
let elapsed = started.elapsed().as_nanos();
assert_eq!(program.functions.len(), expected_project_functions);
black_box(&program);
println!("audit_compiler case={label} phase=project round={round} ns={elapsed}");
}
}
let project = Project::load(&fixtures.join("tenant")).unwrap();
let program = project.check().unwrap();
let roots = program.entries().collect::<Vec<_>>();
assert!(!roots.is_empty());
println!(
"audit_compiler input=tenant roots={} repeats=64 rounds=15",
roots.len()
);
for round in 1..=15 {
let started = Instant::now();
let mut required = 0usize;
for _ in 0..64 {
for root in &roots {
let access = black_box(&program).tenant_access(black_box(*root));
required += usize::from(access.required);
black_box(access);
}
}
let elapsed = started.elapsed().as_nanos();
println!(
"audit_compiler case=tenant phase=access round={round} ns={elapsed} required={required}"
);
}
}
+109
View File
@@ -0,0 +1,109 @@
use super::{checked, package::packages};
#[test]
fn api_snapshot_uses_declared_contracts_not_private_bodies_or_inferred_constants() {
let snapshot = |body: &str| {
checked(&format!("public value(input: Int) (answer: Int) {{ answer = helper(input) }}\nhelper(input: Int) (answer: Int) {{ {body} }}")).unwrap().api_snapshot()
};
let first = snapshot("answer = 1");
assert_eq!(
first,
"dever-api 5\nfunction main.value(Int) (answer: Int []) pure=false recover=None effects=[] errors=[] recoveries={}\n"
);
assert_eq!(first, snapshot("answer = input + 2"));
let constrained =
checked("public value(input: Int) (answer: Int >= 0) { answer = 1 }").unwrap();
assert!(
constrained
.api_snapshot()
.contains("answer: Int [0..9223372036854775807]")
);
let difference = constrained.check_api(&first).unwrap_err();
assert!(
difference.contains("- function main.value")
&& difference.contains("+ function main.value")
);
}
#[test]
fn api_snapshot_tracks_public_shape_construction_failure_and_transitive_recovery() {
let snapshot = |key: &str| {
checked(&format!(
"public type Session {{ private key: {key}\nlabel: Text }}"
))
.unwrap()
.api_snapshot()
};
let private = snapshot("Int");
assert_eq!(private, snapshot("Text"));
assert_eq!(
private,
"dever-api 5\ntype main.Session record construct=private {label: Text []}\n"
);
let exposed = checked("public type Session { key: Int\nlabel: Text }").unwrap();
assert!(
exposed
.check_api(&private)
.unwrap_err()
.contains("construct=public")
);
let failing = checked("public type Problem { error Bad(message: Text) }\npublic value() () { fail(Problem.Bad(\"bad\")) }").unwrap();
let baseline = failing.api_snapshot();
assert!(baseline.contains("errors=[main.Problem.Bad]"));
assert!(baseline.contains("type main.Problem error Bad(message: Text [])"));
let snapshot = |reason: &str| {
checked(&format!(
"public value() () {{ helper() }}\nhelper() () recover(\"{reason}\") {{}}"
))
.unwrap()
.api_snapshot()
};
assert_ne!(snapshot("use zero"), snapshot("use one"));
assert!(snapshot("use zero").contains("recoveries={\"use zero\"}"));
}
#[test]
fn api_snapshot_is_stable_across_app_topics_and_binds_command_inputs() {
let snapshot = |topic: &str, input: &str| {
packages(&[
(
topic,
&format!("value({input}: Int) (answer: Int) {{ answer = {input} }}"),
),
("shop.account.api", "cmd value = app.value"),
])
.unwrap()
.api_snapshot()
};
assert_eq!(
snapshot("shop.account.app", "input"),
snapshot("shop.account.app.read", "input")
);
assert_ne!(
snapshot("shop.account.app", "input"),
snapshot("shop.account.app", "renamed")
);
assert!(
snapshot("shop.account.app", "input")
.contains("cmd shop.account.value = shop.account.value(input: Int [])")
);
let program = checked("public apply(action: handler(input: Int) (answer: Int), input: Int) (answer: Int) { answer = action(input) }").unwrap();
assert!(
program
.api_snapshot()
.contains("function main.apply(handler(Int [])(answer: Int []), Int)")
);
}
#[test]
fn public_handler_snapshot_keeps_constraints_and_ignores_specializations() {
let source = "public apply(action: handler(input: Int >= 0) (answer: Int >= 0)) (answer: Int >= 0) { answer = action(1) }";
let original = checked(source).unwrap().api_snapshot();
assert!(
original.contains(
"handler(Int [0..9223372036854775807])(answer: Int [0..9223372036854775807])"
)
);
let called = checked(&format!("{source}\nnonnegative(input: Int) (answer: Int >= 0) {{ answer = 0 }}\nmain() (answer: Int) {{ answer = apply(nonnegative) }}")).unwrap();
assert_eq!(original, called.api_snapshot());
}
+442
View File
@@ -0,0 +1,442 @@
use super::package::packages;
#[test]
fn fixed_wire_budget_proof_retains_dynamic_checks_at_schema_limits() {
for (levels, fields, input_fixed, output_fixed) in [
(63, 1, true, true),
(64, 1, false, false),
(14, 2, true, true),
(15, 2, true, false),
(16, 2, false, false),
] {
let mut app = String::new();
let mut kind = "Int".to_owned();
for level in 0..levels {
let members = if fields == 1 {
format!("value: {kind}")
} else {
format!("first: {kind}\nsecond: {kind}")
};
app.push_str(&format!("type Layer{level} {{ {members} }}\n"));
kind = format!("Layer{level}");
}
app.push_str(&format!(
"run(value: {kind}) (answer: {kind}) {{ answer = value }}"
));
let program = packages(&[
("shop.account.app", &app),
(
"shop.account.api",
"cmd run = app.run\npublic post run = app.run",
),
])
.unwrap();
let emitted = dever::compiler::emit::application::generate(&program);
assert_eq!(
emitted.contains("wire::decode_fixed(input)"),
input_fixed,
"{levels}/{fields}"
);
assert_eq!(
emitted.contains("input.decode(dever_runtime::wire::decode_fixed)"),
input_fixed,
"HTTP {levels}/{fields}"
);
assert_eq!(
emitted.matches("wire::response_fixed(&result)").count(),
usize::from(output_fixed),
"{levels}/{fields}"
);
assert_eq!(
emitted.contains("wire::response_fixed_bytes(&result)"),
output_fixed,
"HTTP {levels}/{fields}"
);
}
for (kind, decoder, encoder) in [
("List<Int>", "decode_shaped", "response"),
("Json", "decode_json", "response_with"),
("List<Json>", "decode", "response_with"),
("List<Text>?", "decode_shaped", "response"),
] {
let app = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
let program = packages(&[
("shop.account.app", &app),
(
"shop.account.api",
"cmd run = app.run\npublic post run = app.run",
),
])
.unwrap();
let emitted = dever::compiler::emit::application::generate(&program);
if decoder == "decode_json" {
assert!(emitted.contains("let args: Args0 = decode_args0(input)?;"));
assert!(emitted.contains("input.decode(decode_httpargs0)"));
assert_eq!(emitted.matches("wire::decode_json(input,").count(), 2);
assert_eq!(
emitted.contains("wire::decode(input)"),
kind == "List<Json>"
);
} else {
assert!(!emitted.contains("wire::decode_json("));
assert!(
emitted.contains(&format!("wire::{decoder}(input)")),
"{kind}"
);
assert!(
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
"{kind}"
);
}
assert!(emitted.contains(&format!("wire::{encoder}(")), "{kind}");
}
}
#[test]
fn json_input_budget_includes_fixed_records_and_optional_json() {
let program = packages(&[
(
"shop.account.app",
"type Row { payload: Json?\nnote: Text? }\nrun(row: Row?, other: Json, label: Text) (answer: Row?, extra: Json, name: Text) { answer = row\nextra = other\nname = label }",
),
(
"shop.account.api",
"cmd run = app.run\npublic post run = app.run",
),
])
.unwrap();
let generated = dever::compiler::emit::application::generate(&program);
assert_eq!(generated.matches("wire::decode_json(input,").count(), 2);
assert!(generated.contains("budget.open()?; budget.value(3)?;"));
assert!(generated.contains("budget.open()?; budget.value(2)?;"));
assert!(generated.contains("if let Some(item) = (&(args).field0).as_ref()"));
assert!(generated.contains("budget.json(item)?;"));
assert!(generated.contains("budget.json(&(args).field1)?;"));
assert!(!generated.contains("Json::from_validated_raw"));
assert!(!generated.contains("Json::deserialize_validated"));
}
#[test]
fn json_input_with_any_typed_list_keeps_whole_input_validation() {
for kind in ["List<Int>", "List<Json>", "List<Json?>?"] {
for nested in [false, true] {
let (definition, kind) = if nested {
(format!("type Row {{ values: {kind} }}\n"), "Row?")
} else {
(String::new(), kind)
};
let app = format!(
"{definition}run(payload: Json, other: {kind}) (answer: Json, extra: {kind}) {{ answer = payload\nextra = other }}"
);
let program = packages(&[
("shop.account.app", &app),
(
"shop.account.api",
"cmd run = app.run\npublic post run = app.run",
),
])
.unwrap();
let generated = dever::compiler::emit::application::generate(&program);
assert!(generated.contains("let args: Args0 = dever_runtime::wire::decode(input)?;"));
assert!(generated.contains("input.decode(dever_runtime::wire::decode)"));
assert!(!generated.contains("wire::decode_json("));
assert!(!generated.contains("budget."));
}
}
}
#[test]
fn variable_wire_depth_is_proved_before_skipping_validation() {
for (levels, decoder) in [(63, "decode_shaped"), (64, "decode")] {
let kind = format!("{}Int{}", "List<".repeat(levels), ">".repeat(levels));
let app = format!("run(value: {kind}) (answer: Int) {{ answer = length(value) }}");
let program = packages(&[
("shop.account.app", &app),
(
"shop.account.api",
"cmd run = app.run\npublic post run = app.run",
),
])
.unwrap();
let emitted = dever::compiler::emit::application::generate(&program);
assert!(
emitted.contains(&format!("wire::{decoder}(input)")),
"{levels}"
);
assert!(
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
"{levels}"
);
}
}
#[test]
fn http_entries_bind_typed_apps_and_keep_protected_entries_closed() {
assert!(packages(&[("main", "get(value: Int) (answer: Int) { answer = value }")]).is_ok());
let app = "echo(value: Int) (answer: Int) { answer = value }";
let program = packages(&[
("shop.account.app", app),
(
"shop.account.api.admin.session",
"public get status = app.echo",
),
])
.unwrap();
assert_eq!(program.http[0].path, "/shop/account/admin/session/status");
assert_eq!(program.http[0].topic, "admin/session");
assert_eq!(program.http[0].method, "GET");
for (declaration, code) in [
("get status = app.echo", "C014"),
("authenticated get status = app.echo", "C014"),
(
"public get status = app.echo\npublic get status = app.echo",
"C002",
),
("public get status = other.echo", "C006"),
] {
assert_eq!(
packages(&[("shop.account.app", app), ("shop.account.api", declaration)])
.unwrap_err()
.code,
code
);
}
assert_eq!(
packages(&[
(
"shop.account.app",
"echo(value: List<Int>) (answer: List<Int>) { answer = value }"
),
("shop.account.api", "public get status = app.echo")
])
.unwrap_err()
.code,
"C005"
);
}
#[test]
fn command_wire_rejects_nested_constrained_records() {
for kind in ["Port", "List<Port?>", "Envelope"] {
let app = format!(
"type Port {{ value: Int >= 1 }}\ntype Envelope {{ ports: List<Port?> }}\nrun(value: {kind}) (answer: {kind}) {{ answer = value }}"
);
assert_eq!(
packages(&[
("shop.account.app", &app),
("shop.account.api", "cmd run = app.run")
])
.unwrap_err()
.code,
"C011"
);
}
}
#[test]
fn application_topics_share_app_contracts_and_domain_dependencies() {
let program = packages(&[
("shop.account.app.read", "type User { name: Text }\nread(name: Text) (answer: User) { answer = User { name = domain.label(name) } }"),
("shop.account.app.write", "echo(user: User) (answer: User) { answer = user }"),
("shop.account.domain", "label(name: Text) (answer: Text) { answer = name + \"!\" }"),
("shop.account.api", "cmd read = app.read\ncmd echo = app.echo"),
]).unwrap();
assert_eq!(program.commands.len(), 2);
assert_eq!(program.commands[0].name, "shop.account.read");
assert!(
program
.functions
.iter()
.any(|function| function.signature.name == "label")
);
}
#[test]
fn private_app_fields_share_topic_ownership_and_reject_other_domains() {
let declaration = "type Session { private key: Text\nlabel: Text }";
assert!(packages(&[
("shop.account.app.types", declaration),
("shop.account.app.write", "make(key: Text) (answer: Session) { answer = Session { key = key, label = \"name\" } }"),
("shop.account.app.read", "read(value: Session) (answer: Text) { value.key = value.key + \"!\"\nanswer = value.key }"),
]).is_ok());
for operation in [
"answer = value.key",
"value.key = \"changed\"\nanswer = value.label",
"value = shop.account.Session { key = \"hidden\", label = \"name\" }\nanswer = value.label",
] {
let caller = format!("read(value: shop.account.Session) (answer: Text) {{ {operation} }}");
assert_eq!(
packages(&[
("shop.account.app", declaration),
("shop.other.app", &caller)
])
.unwrap_err()
.code,
"C006"
);
}
}
#[test]
fn command_wire_rejects_nested_private_records_in_both_directions() {
for kind in ["Session", "List<Session?>", "Envelope"] {
for function in [
format!("run(value: {kind}) () {{}}"),
format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}"),
] {
let app = format!(
"type Session {{ private key: Text }}\ntype Envelope {{ sessions: List<Session?> }}\n{function}"
);
let error = packages(&[
("shop.account.app", &app),
("shop.account.api", "cmd run = app.run"),
])
.unwrap_err();
assert_eq!(error.code, "C006", "{error:?}");
assert!(error.message.contains("wire boundary"), "{error:?}");
}
}
let app = "type Session { private key: Text }\nrun() (answer: Session) { answer = Session { key = \"hidden\" } }";
assert_eq!(
packages(&[
("shop.account.app", app),
("shop.account.api", "cmd run = app.run")
])
.unwrap_err()
.code,
"C006"
);
}
#[test]
fn application_role_access_cannot_be_bypassed_with_qualified_names() {
for (source, target) in [
("shop.first.domain", "shop.second.run"),
("shop.first.domain", "app.run"),
("shop.first.app", "shop.second.domain.hidden"),
] {
let caller = format!("entry() (answer: Int) {{ answer = {target}() }}");
let result = packages(&[
(source, &caller),
("shop.first.app.extra", "run() (answer: Int) { answer = 1 }"),
("shop.second.app", "run() (answer: Int) { answer = 2 }"),
(
"shop.second.domain",
"hidden() (answer: Int) { answer = 3 }",
),
]);
assert_eq!(result.unwrap_err().code, "C006", "{source}: {target}");
}
assert_eq!(
packages(&[
("shop.account.app", "type Visible { secret: domain.Hidden }"),
("shop.account.domain", "type Hidden { value: Int }"),
])
.unwrap_err()
.code,
"C006"
);
assert_eq!(
packages(&[("shop.account.app", "public run() () {}")])
.unwrap_err()
.code,
"C006"
);
assert_eq!(
packages(&[("shop.account.api", "run() () {}")])
.unwrap_err()
.code,
"C006"
);
assert_eq!(
packages(&[("main", "cmd run = app.run")]).unwrap_err().code,
"C006"
);
}
#[test]
fn command_secret_inputs_use_the_existing_input_codec_without_output_access() {
for kind in ["Secret", "Secret?", "List<Secret>"] {
let app = format!("run(value: {kind}) (answer: Bool) {{ answer = true }}");
assert!(
packages(&[
("shop.account.app", &app),
("shop.account.api", "cmd run = app.run")
])
.is_ok()
);
let leaking = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
assert!(
packages(&[
("shop.account.app", &leaking),
("shop.account.api", "cmd run = app.run")
])
.is_err()
);
}
}
#[test]
fn commands_reject_ambiguous_targets_and_invalid_wire_contracts() {
for app in [
"run() () {}\nrun(value: Int) () {}",
"run(action: handler() ()) () { action() }",
"type State { Ready(value: Int) }\nrun(value: State) (answer: State) { answer = value }",
"type State { Ready\n error Failed }\nrun(value: State) (answer: State) { answer = value }",
] {
assert_eq!(
packages(&[
("shop.account.app", app),
("shop.account.api", "cmd run = app.run")
])
.unwrap_err()
.code,
"C005"
);
}
assert_eq!(
packages(&[
("shop.account.app", "run() () {}"),
("shop.account.api", "cmd run = app.run\ncmd run = app.run"),
])
.unwrap_err()
.code,
"C002"
);
assert_eq!(
packages(&[
("shop.account.app", "run() () {}"),
("shop.account.api", "cmd run = shop.account.run"),
])
.unwrap_err()
.code,
"C006"
);
}
#[test]
fn application_entry_reachability_and_domain_cycles_are_checked() {
assert_eq!(
packages(&[
("shop.account.app", "run() () {}\nunused() () {}"),
("shop.account.api", "cmd run = app.run"),
])
.unwrap_err()
.code,
"C016"
);
assert_eq!(packages(&[
("shop.first.app", "run() (answer: Int) { answer = second.read() }\nread() (answer: Int) { answer = 1 }"),
("shop.second.app", "read() (answer: Int) { answer = first.read() }"),
("shop.first.api", "cmd run = app.run"),
]).unwrap_err().code, "C007");
assert_eq!(
packages(&[
("shop.account.app.read", "run() () {}"),
("shop.account.app.write", "run() () {}"),
])
.unwrap_err()
.code,
"C013"
);
}
+103
View File
@@ -0,0 +1,103 @@
use super::{APP, ERROR, packages};
const CLOCK: &str = "public type ClockResult { Read(value: Int)\nerror Failed(message: Text) }\npublic unixMillis() (value: Int) { value = read(dever.system.timeUnixMillis()) }\nread(result: ClockResult.Read(number)) (value: Int) { value = number }\nread(result: ClockResult.Failed(message)) (value: Int) { fail(ClockResult.Failed(message)) }\npublic type SleepResult { Done\nerror Failed(message: Text) }\npublic sleep(millis: Int) () { slept(dever.system.timeSleep(millis)) }\nslept(result: SleepResult.Done) () {}\nslept(result: SleepResult.Failed(message)) () { fail(SleepResult.Failed(message)) }";
const TASK: &str = "public sleep(millis: Int) () { slept(dever.system.taskSleep(millis)) }\nslept(result: dever.time.SleepResult.Done) () {}\nslept(result: dever.time.SleepResult.Failed(message)) () { fail(dever.time.SleepResult.Failed(message)) }";
#[test]
fn provider_expiration_reads_clock_but_cannot_wait() {
for (operation, error) in [
("current = dever.time.unixMillis()", None),
("dever.time.sleep(1)\ncurrent = 0", Some("C013")),
("dever.task.sleep(1)\ncurrent = 0", Some("C014")),
] {
let app = APP
.replace(
"account = model.first",
&format!("{operation}\naccount = model.first"),
)
.replace(
"session == claims.session",
"session == claims.session and expires_at > current",
);
let result = packages(&[
("dever.database", ERROR),
("dever.api", "public type Error { error Unauthorized }"),
(
"dever.auth",
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
),
("dever.time", CLOCK),
("dever.task", TASK),
(
"demo.account.model",
"global type Account { subject: Text\nsession: Text\nexpires_at: Int }",
),
("demo.account.app", &app),
("demo.account.api", "public get health = app.health"),
]);
match error {
Some(code) => assert_eq!(result.unwrap_err().code, code, "{operation}"),
None => {
let program = result.unwrap();
let effects = program.functions[program.verifiers[0].function]
.signature
.effects;
assert!(effects.clock);
assert!(!effects.timer);
}
}
}
}
#[test]
fn shipped_session_expiration_contract_checks_without_dropping_the_deadline() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../skills/assets/examples/http-rest");
let project = dever::compiler::project::Project::load(&root).unwrap();
let program = project.check().unwrap();
assert!(
program
.verifiers
.iter()
.any(|verifier| { program.functions[verifier.function].signature.effects.clock })
);
}
#[test]
fn provider_checks_every_model_loaded_by_a_relation() {
let model =
"global type Account { subject: Text\nsession: Text\nprofile_id: demo.profile.model.id }";
let app = APP.replace(
"session == claims.session })",
"session == claims.session, with = [profile] })",
);
for global in [true, false] {
let profile = format!(
"{}type Profile {{ name: Text }}",
if global { "global " } else { "" }
);
let result = packages(&[
("dever.database", ERROR),
("dever.api", "public type Error { error Unauthorized }"),
(
"dever.auth",
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
),
("demo.account.model", model),
("demo.profile.model", &profile),
("demo.account.app", &app),
("demo.account.api", "public get health = app.health"),
]);
if global {
result.unwrap();
} else {
let error = result.unwrap_err();
assert_eq!(error.code, "C014");
assert!(
error
.message
.contains("Provider verify cannot read a tenant Model")
);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
use super::{database::ERROR, package::packages};
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
#[path = "../authorization/mod.rs"]
mod capability;
mod effects;
mod request;
mod rest;
const APP: &str = "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (identity: Identity) { account = model.first({ where = subject == claims.subject and session == claims.session })\nidentity = identity(account) }\nidentity(account: model.Account) (identity: Identity) { identity = Identity { id = account.subject, user_id = account.id, tenant_id = null } }\nidentity(account: null) (identity: Identity) { fail(dever.api.Error.Unauthorized) }\nhealth() (value: Bool) { value = true }";
fn checked(model: &str, app: &str) -> Result<Program, Diagnostic> {
packages(&[
("dever.database", ERROR),
("dever.api", "public type Error { error Unauthorized }"),
(
"dever.auth",
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
),
("demo.account.model", model),
("demo.account.app", app),
("demo.account.api", "public get health = app.health"),
])
}
#[test]
fn provider_identity_is_a_compiled_app_entry_with_global_reads() {
let program = checked("global type Account { subject: Text\nsession: Text }", APP).unwrap();
let verifier = &program.verifiers[0];
assert_eq!(verifier.name, "demo.account.verify");
assert!(program.models[0].global);
assert!(program.entries().any(|entry| entry == verifier.function));
assert!(program.api_snapshot().contains("global=true"));
assert_eq!(
checked("type Account { subject: Text\nsession: Text }", APP)
.unwrap_err()
.code,
"C014"
);
assert_eq!(
packages(&[("main", "global type Value { number: Int }")])
.unwrap_err()
.code,
"C006"
);
}
#[test]
fn provider_cannot_write_or_replace_nominal_identity_with_permissions() {
let model = "global type Account { subject: Text\nsession: Text }";
let writing = APP.replace(
"model.first({ where = subject == claims.subject and session == claims.session })",
"model.create({ subject = claims.subject, session = claims.session })",
);
assert_eq!(checked(model, &writing).unwrap_err().code, "C014");
let invalid = APP
.replace("user_id: model.id?", "user_id: Int?")
.replace("user_id = account.id", "user_id = 1");
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
let invalid = APP
.replace(
"tenant_id: model.id?",
"tenant_id: model.id?\npermissions: List<Text>",
)
.replace("tenant_id = null", "tenant_id = null, permissions = []");
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
}
+61
View File
@@ -0,0 +1,61 @@
use super::packages;
const ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden }";
const AUTH: &str = "public id() (value: Text) { value = dever.system.authId() }";
#[test]
fn identity_cannot_escape_to_public_commands_domains_or_tasks() {
for api in ["public get me = app.me", "cmd me = app.me"] {
let error = packages(&[
("dever.api", ERROR),
("dever.auth", AUTH),
(
"demo.user.app",
"me() (value: Text) { value = dever.auth.id() }",
),
("demo.user.api", api),
])
.unwrap_err();
assert_eq!(error.code, "C014", "{api}: {error:?}");
}
for (package, source, code) in [
(
"demo.user.domain",
"me() (value: Text) { value = dever.auth.id() }",
"C014",
),
(
"main",
"main() (value: Text) { task = run(dever.auth.id())\nvalue = wait(task) }",
"C013",
),
(
"main",
"main() (value: Text) { value = dever.system.authId() }",
"C006",
),
] {
assert_eq!(
packages(&[
("dever.api", ERROR),
("dever.auth", AUTH),
(package, source)
])
.unwrap_err()
.code,
code
);
}
}
#[test]
fn provider_cannot_reenter_request_identity() {
let error = packages(&[
("dever.api", ERROR),
("dever.auth", "public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }\npublic id() (value: Text) { value = dever.system.authId() }"),
("demo.user.model", "global type User { name: Text }"),
("demo.user.app", "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (value: Identity) { value = Identity { id = dever.auth.id(), user_id = null, tenant_id = null } }\nhealth() (value: Bool) { value = true }"),
("demo.user.api", "public get health = app.health"),
]).unwrap_err();
assert_eq!(error.code, "C014");
}
+202
View File
@@ -0,0 +1,202 @@
use super::*;
fn rest(model: &str, domain: &str) -> Result<Program, Diagnostic> {
packages(&[
("dever.database", ERROR),
("dever.api", "public type Error { error Unauthorized }"),
(
"dever.auth",
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
),
(
"text",
"public trim(input: Text) (value: Text) pure { value = input }",
),
(
"demo.account.model",
"global type Account { subject: Text\nsession: Text }",
),
("demo.account.model.profile", model),
("demo.account.domain", domain),
("demo.account.app", APP),
(
"demo.account.api.admin.session",
"public get health = app.health",
),
("demo.account.api.admin.profile", "rest model.profile"),
])
}
#[test]
fn rest_wire_reuses_schema_proof_without_skipping_json_validation() {
for (kind, decoder) in [
("Text", "decode_fixed"),
("Int?", "decode_fixed"),
("Json", "decode"),
("Json?", "decode"),
] {
let program = rest(&format!("type Profile {{ value: {kind} }}"), "").unwrap();
let generated = dever::compiler::emit::application::generate(&program);
assert!(!generated.contains("wire::decode_json("));
for operation in ["Create", "Replace"] {
assert!(
generated.contains(&format!(
"let args: Rest{operation}0 = input.decode(dever_runtime::wire::{decoder})"
)),
"{kind}/{operation}"
);
}
if kind == "Json" {
assert!(
generated.contains("field0: Box<dever_runtime::wire::serde_json::value::RawValue>")
);
assert!(generated.contains("Json::from_validated_raw(decoded.field0)"));
} else if kind == "Json?" {
assert!(
generated.contains(
"field0: Option<Box<dever_runtime::wire::serde_json::value::RawValue>>"
)
);
assert!(generated.contains(
"decoded.field0.map(dever_runtime::wire::json::Json::from_validated_raw)"
));
} else {
assert!(!generated.contains("Json::deserialize_validated"));
assert!(!generated.contains("Json::deserialize_optional_validated"));
}
}
}
#[test]
fn rest_json_output_projects_only_public_fields() {
let program = rest(
"type Profile { value: Json\nprivate secret: Text create = \"hidden\"\nextra: Json? }",
"",
)
.unwrap();
let generated = dever::compiler::emit::application::generate(&program);
let view = generated
.split_once("impl RestView0<'_> {")
.unwrap()
.1
.split_once("struct RestItems0")
.unwrap()
.0;
assert!(view.contains("field2"));
assert!(!view.contains("field3"));
assert!(view.contains("field4"));
assert!(!view.contains("secret"));
assert!(!view.contains("serialize_field"));
}
#[test]
fn rest_json_defaults_keep_their_typed_decoder() {
for (kind, value, decoder) in [
("Json", r#""{}""#, "deserialize_validated"),
("Json?", "null", "deserialize_optional_validated"),
] {
let program = rest(
&format!("type Profile {{ value: {kind} default {value} }}"),
"",
)
.unwrap();
let generated = dever::compiler::emit::application::generate(&program);
assert!(generated.contains(&format!("Json::{decoder}")));
assert!(generated.contains("#[serde(default=\"rest_create_0_default0\")]"));
}
}
#[test]
fn rest_bindings_use_checked_functions_and_preserve_owner_identity() {
let program = rest("type Profile { private owner user_id: model.id = dever.auth.userId()\nname: Text unique create = domain.normalize(input) replace = text.trim(input) search = input\nprivate source: Text create = \"source\" replace = \"replaced\" }","normalize(input: Text) (value: Text) pure { value = input }").unwrap();
let entry = &program.rest[0];
assert_eq!(entry.owner.as_ref().unwrap().position, 2);
assert!(!entry.read.contains(&2));
assert_eq!(
entry.create.iter().filter(|binding| binding.input).count(),
1
);
assert_eq!(entry.search.as_ref().unwrap().position, 3);
assert!(
entry
.functions()
.all(|function| program.entries().any(|entry| entry == function))
);
}
#[test]
fn rest_rejects_unsafe_field_sources_and_ambiguous_search() {
for model in [
"type Profile { private name: Text create = input }",
"type Profile { owner id_value: Int? = dever.auth.userId()\nname: Text }",
"type Profile { owner first_id: Int = dever.auth.userId()\nowner second_id: Int = dever.auth.tenantId()\nname: Text }",
"type Profile { name: Text search = input }",
"type Profile { name: Text index search = \"fixed\" }",
"type Profile { name: Text index search = input\nother_name: Text index search = input }",
"type Profile { private required: Text\nname: Text }",
"type Profile { name: Text create = domain.normalize(input) }",
] {
let error = rest(
model,
if model.contains("domain.normalize") {
"normalize(input: Text) (value: Text) { value = input }"
} else {
""
},
)
.unwrap_err();
assert_eq!(error.code, "C014", "{model}: {error:?}");
}
}
#[test]
fn rest_search_reserves_pagination_names_without_banning_storage_fields() {
for name in ["page", "size"] {
let model = format!("type Profile {{ {name}: Int index search = input }}");
let error = rest(&model, "").unwrap_err();
assert_eq!(error.code, "C014");
assert_eq!(
error.message,
"REST search field cannot use reserved pagination names"
);
let model = format!("type Profile {{ {name}: Int index }}");
assert!(
rest(&model, "").is_ok(),
"{name} remains a valid storage field"
);
}
}
#[test]
fn rest_preserves_required_private_fields_with_only_a_create_source() {
let program = rest(
"type Profile { private created_by: Text create = \"creator\"\nname: Text }",
"",
)
.unwrap();
let entry = &program.rest[0];
let source = entry
.create
.iter()
.find(|binding| binding.position == 2)
.unwrap();
assert!(source.function.is_some());
assert!(!source.input);
assert!(!entry.read.contains(&2));
assert!(entry.replace.iter().all(|binding| binding.position != 2));
let error = rest(
"type Profile { private created_by: Text replace = \"replacement\"\nname: Text }",
"",
)
.unwrap_err();
assert_eq!(error.code, "C014");
assert_eq!(
error.message,
"REST field is private and required without a source"
);
}
#[test]
fn rest_field_metadata_does_not_escape_into_handler_contracts() {
assert!(packages(&[("main","apply(callback: handler(value: Int create = input) (answer: Int)) (answer: Int) { answer = callback(1) }")]).is_err());
}
+171
View File
@@ -0,0 +1,171 @@
use super::{APP, ERROR};
use dever::{
compiler::{check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
const API_ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden\nerror NotFound\nerror Conflict\nerror TooManyRequests }";
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
let app = format!("{APP}\n{app}");
let api = format!("{api}\npublic get health = app.health");
let inputs = [
("dever.database", "database.dever", ERROR),
("dever.api", "api.dever", API_ERROR),
(
"dever.auth",
"values.dever",
include_str!("../../../../lib/dever/auth/values.dever"),
),
(
"dever.auth",
"capability.dever",
include_str!("../../../../lib/dever/auth/capability.dever"),
),
(
"demo.account.model",
"model.dever",
"global type Account { subject: Text\nsession: Text }",
),
("demo.account.app", "app.dever", app.as_str()),
("demo.account.api", "api.dever", api.as_str()),
];
let sources = inputs
.iter()
.enumerate()
.map(|(index, (_, path, text))| Source::new(index, *path, (*text).into()))
.collect::<Vec<_>>();
let modules = sources
.iter()
.map(dever::syntax::parse)
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(name, _, _)| (*name).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn capability_operations_keep_original_types_and_write_effects() {
let program = checked("", "").unwrap();
for function in program.functions.iter().filter(|function| {
matches!(
function.signature.name.as_str(),
"register_capability" | "remove_capability"
)
}) {
let effects = function.signature.effects;
assert!(effects.identity && effects.authorization_write && effects.database_write);
}
let error = checked(
"register(id: Text) (key: Text) { key = dever.auth.registerCapability(id) }",
"post register = app.register",
)
.unwrap_err();
assert_eq!(error.code, "C005");
let error = checked(
"register(id: Uuid) (value: Bool) { value = dever.auth.registerCapability(id) }",
"post register = app.register",
)
.unwrap_err();
assert_eq!(error.code, "C005");
}
#[test]
fn capability_writes_require_protected_http_and_keep_transaction_rules() {
for api in [
"public post register = app.register",
"authenticated post register = app.register",
"get register = app.register",
"cmd register = app.register",
"long post register = app.register",
] {
let error = checked(
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
api,
)
.unwrap_err();
assert_eq!(error.code, "C014", "{api}: {error:?}");
}
let program = checked(
"transaction register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
"long post register = app.register",
)
.unwrap();
assert_tenant_requirement(&program, true);
}
#[test]
fn protected_role_query_is_authenticated_read_only_and_not_a_tenant_selector() {
let app =
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id) }";
for api in [
"get protected = app.protected",
"authenticated get protected = app.protected",
] {
let program = checked(app, api).unwrap();
assert_tenant_requirement(&program, true);
}
for api in [
"public get protected = app.protected",
"cmd protected = app.protected",
] {
assert_eq!(checked(app, api).unwrap_err().code, "C014");
}
assert_eq!(checked(
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id, 42) }",
"get protected = app.protected",
).unwrap_err().code, "C005");
}
fn assert_tenant_requirement(program: &Program, required: bool) {
let entry = program
.http
.iter()
.find(|entry| entry.access != dever::compiler::model::auth::Access::Public)
.unwrap();
assert_eq!(program.tenant_access(entry.function).required, required);
}
#[test]
fn protected_write_entries_cannot_replace_the_required_tenant() {
for (app, api) in [
(
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
"post register = app.register",
),
(
"remove(id: Uuid) () { dever.auth.removeCapability(id) }",
"delete remove = app.remove",
),
] {
assert_tenant_requirement(&checked(app, api).unwrap(), true);
}
}
#[test]
fn tenant_requirement_follows_reachable_calls_and_static_handlers() {
for (app, required) in [
(
"register(id: Uuid) (key: Text) { key = helper(id) }\nhelper(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
true,
),
(
"register(id: Uuid) (key: Text) { key = apply(dever.auth.registerCapability, id) }\napply(action: handler(id: Uuid) (key: Text), id: Uuid) (key: Text) { key = action(id) }",
true,
),
] {
assert_tenant_requirement(
&checked(app, "post register = app.register").unwrap(),
required,
);
}
let error = checked(
"register(id: Uuid) (key: Text) { key = \"unused\" }\nunused(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
"post register = app.register",
).unwrap_err();
assert_eq!(error.code, "C016");
}
+258
View File
@@ -0,0 +1,258 @@
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let sources = sources(text);
let modules = sources
.iter()
.map(dever::syntax::parse)
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&["main".into(), "dever.bytes".into(), "text".into()],
)
}
fn sources(text: &str) -> [Source; 3] {
[
Source::new(0, "main.dever", text.into()),
Source::new(
1,
"lib/dever/bytes.dever",
include_str!("../../../lib/dever/bytes.dever").into(),
),
Source::new(
2,
"lib/text.dever",
include_str!("../../../lib/text.dever").into(),
),
]
}
fn generated_entry(text: &str) -> String {
let mut program = linked(text).unwrap();
let entry = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
// 改名不改变内建操作身份,防止优化依赖 concat/from_text 的函数名称。
for (index, function) in program.functions.iter_mut().enumerate() {
function.signature.name = format!("renamed_{index}");
}
let generated = dever::compiler::emit::generate(&program, &sources(text), &[entry].into());
let start = generated.find(&format!("pub fn function{entry}(")).unwrap();
generated[start..]
.split("\npub ")
.next()
.unwrap()
.to_owned()
}
#[test]
fn borrowed_bytes_keep_their_shared_owner() {
let generated = generated_entry("main(bytes: Bytes) (answer: Bytes) { answer = bytes }");
assert!(
generated.contains("arg0: &dever_runtime::Bytes"),
"{generated}"
);
assert!(generated.contains(".to_owned()"), "{generated}");
assert!(!generated.contains("copy_from_slice"), "{generated}");
}
#[test]
fn text_byte_views_are_limited_to_non_escaping_iteration() {
let generated = generated_entry(
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) }",
);
assert!(generated.contains(".as_bytes()"), "{generated}");
assert!(!generated.contains("copy_from_slice"), "{generated}");
let generated = generated_entry(
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) + length(bytes) }",
);
assert!(!generated.contains("copy_from_slice"), "{generated}");
for body in [
"main(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = bytes }",
"identity(bytes: Bytes) (answer: Bytes) { answer = bytes }\nmain(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = identity(bytes) }",
] {
let generated = generated_entry(body);
assert!(generated.contains("copy_from_slice"), "{generated}");
}
}
#[test]
fn empty_separator_parts_are_fused_only_when_fully_concatenated() {
let join = "joinPart(item: Bytes, state: Bytes) (answer: Bytes) { answer = dever.bytes.concat(state, item) }\n";
let generated = generated_entry(
&(join.to_owned()
+ "main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = reduce(joinPart, parts, seed) }"),
);
assert!(
generated.contains("state.extend_from_slice(sequence)"),
"{generated}"
);
assert!(!generated.contains("copy_from_slice"), "{generated}");
assert!(!generated.contains("collect::<Vec<_>>()"), "{generated}");
for body in [
"main(text: Text) (answer: List<Bytes>) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = parts }",
"main(text: Text, seed: Bytes) (answer: Bytes, count: Int) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\ncount = length(parts)\nanswer = reduce(joinPart, parts, seed) }",
"main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \",\"))\nanswer = reduce(joinPart, parts, seed) }",
"main(text: Text, separator: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, separator))\nanswer = reduce(joinPart, parts, seed) }",
] {
let generated = generated_entry(&(join.to_owned() + body));
assert!(generated.contains("copy_from_slice"), "{generated}");
}
}
#[test]
fn literal_text_concat_allocates_only_the_final_buffer() {
for literal in [r#""""#, r#""prefix""#, r#""雪\u{0}🙂""#] {
let generated = generated_entry(&format!(
"main(suffix: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(dever.bytes.fromText({literal}), suffix) }}"
));
assert!(
generated.contains("value::bytes::concat(prefix, suffix)"),
"{generated}"
);
assert!(!generated.contains(".to_vec()"), "{generated}");
assert!(!generated.contains("extend_from_slice"), "{generated}");
}
}
#[test]
fn dynamic_text_and_owned_byte_accumulators_keep_their_existing_paths() {
for (source, operation) in [
(
"main(text: Text, suffix: Bytes) (answer: Bytes) { answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
"Bytes::copy_from_slice",
),
(
"main(suffix: Bytes) (answer: Bytes) { text = \"owned\"\n answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
".into_bytes()",
),
(
"main(suffix: Bytes) (answer: Bytes) { bytes = dever.bytes.fromText(\"owned\")\n answer = dever.bytes.concat(bytes, suffix) }",
"bytes.extend_from_slice(suffix)",
),
] {
let generated = generated_entry(source);
assert!(generated.contains(operation), "{generated}");
assert!(
!generated.contains("value::bytes::concat(prefix, suffix)"),
"{generated}"
);
}
}
#[test]
fn bytes_keep_their_declared_collection_contract() {
for source in [
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: List<Int>) { answer = each(identity, bytes) }",
"add(item: Int, state: Int) (next: Int) { next = item + state }\nmain(bytes: Bytes) (answer: Int) { answer = reduce(add, bytes, 0) }",
"main(values: List<Text>) (answer: List<Bytes>) { answer = each(dever.bytes.fromText, values) }",
"main(bytes: Bytes?) (answer: Bytes?) { answer = bytes }",
] {
let result = linked(source);
assert!(result.is_ok(), "{source}: {result:?}");
}
for source in [
"main(bytes: Bytes) (answer: Int) { answer = sum(bytes) }",
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: Int) { answer = sum(identity, bytes) }",
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: List<Int>) { answer = filter(yes, bytes) }",
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: Int?) { answer = find(yes, bytes) }",
"identity(item: Text) (answer: Text) { answer = item }\nmain(bytes: Bytes) (answer: List<Text>) { answer = each(identity, bytes) }",
"main(bytes: Bytes) (answer: List<Int>) { answer = bytes }",
"main(values: List<Int>) (answer: Bytes) { answer = values }",
"main(values: Map<Bytes, Int>) () {}",
] {
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
}
}
#[test]
fn buffer_reductions_append_without_copying_the_accumulator() {
for (kind, body, method) in [
("Text", "state + item", "push_str"),
(
"Bytes",
"dever.bytes.concat(state, item)",
"extend_from_slice",
),
] {
let source = format!(
"appendPiece(item: {kind}, state: {kind}) (answer: {kind}) {{ answer = {body} }}\nmain(values: List<{kind}>, initial: {kind}) (answer: {kind}) {{ answer = reduce(appendPiece, values, initial) }}"
);
let generated = generated_entry(&source);
let iteration = generated.split("for item in sequence").nth(1).unwrap();
assert!(
iteration.contains(&format!("state.{method}(item)")),
"{generated}"
);
for copy in [".clone()", ".to_owned()", ".to_vec()"] {
assert!(!iteration.contains(copy), "{generated}");
}
}
}
#[test]
fn literal_buffer_seeds_do_not_roundtrip_through_immutable_bytes() {
for literal in [r#""""#, r#""seed""#, r#""雪\u{0}🙂""#] {
let generated = generated_entry(&format!(
"appendPiece(item: Bytes, state: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(state, item) }}\nmain(values: List<Bytes>) (answer: Bytes) {{ answer = reduce(appendPiece, values, dever.bytes.fromText({literal})) }}"
));
assert!(generated.contains("let mut state: Vec<u8>"), "{generated}");
assert!(!generated.contains("Bytes::from_static"), "{generated}");
assert!(!generated.contains("Vec::<u8>::from"), "{generated}");
}
}
#[test]
fn library_helpers_and_intrinsics_are_private() {
for source in [
"main() (answer: Bytes) { answer = dever.system.bytesFromText(\"x\") }",
"main() (answer: Bytes) { answer = dever.bytes.ready(dever.bytes.BufferResult.Ready(dever.bytes.fromText(\"x\"))) }",
"main() (answer: Text) { answer = dever.bytes.decoded(dever.bytes.DecodeResult.Decoded(\"x\")) }",
] {
assert_eq!(linked(source).unwrap_err().code, "C006", "{source}");
}
}
#[test]
fn byte_errors_keep_nominal_identity_and_obligations() {
assert_eq!(
linked("main(bytes: Bytes) () { captured = result(dever.bytes.toText(bytes)) }")
.unwrap_err()
.code,
"C012"
);
assert_eq!(linked("type Result { Decoded(text: Text)\n error Failed(message: Text) }\nmain(bytes: Bytes) (answer: Result) { answer = result(dever.bytes.toText(bytes)) }").unwrap_err().code, "C005");
assert!(linked("main(bytes: Bytes) (answer: dever.bytes.DecodeResult) { answer = result(dever.bytes.toText(bytes)) }").is_ok());
assert!(linked("type Result { Ready(text: Text)\n error Failed(message: Text) }\nidentity() (text: Text) { text = \"ok\" }\nmain() (answer: Result) { captured = result(identity())\n answer = captured }").is_ok());
}
#[test]
fn package_names_resolve_without_exposing_private_types() {
let library = Source::new(1, "sample.dever", "type Hidden { value: Int }\npublic type Visible { value: Int }\npublic read(value: Int) (answer: Int) { answer = value }".into());
for (source, accepted) in [
(
"main() (answer: sample.Visible) { answer = sample.Visible { value = sample.read(1) } }",
true,
),
("main(value: sample.Hidden) () {}", false),
("main() () { value = sample.Hidden { value = 1 } }", false),
(
"main() (answer: Visible) { answer = sample.Visible { value = 1 } }",
false,
),
] {
let source = Source::new(0, "main.dever", source.into());
let result = check::check(
vec![
dever::syntax::parse(&source).unwrap(),
dever::syntax::parse(&library).unwrap(),
],
&["main".into(), "sample".into()],
);
assert_eq!(result.is_ok(), accepted, "{result:?}");
}
}
+95
View File
@@ -0,0 +1,95 @@
use dever::{
compiler::{build, check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
("dever.time", include_str!("../../../lib/dever/time.dever")),
(
"dever.time",
include_str!("../../../lib/dever/time/calendar.dever"),
),
(
"dever.time",
include_str!("../../../lib/dever/time/operations.dever"),
),
(
"dever.time",
include_str!("../../../lib/dever/time/clock.dever"),
),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (_, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("library{index}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn calendar_types_are_distinct_and_only_clock_reads_have_effects() {
let program = linked("main() (answer: Text) pure { instant = dever.time.parseDatetime(\"1970-01-01T00:00:00Z\")\n answer = dever.time.formatDatetime(dever.time.add(instant, dever.time.duration(1))) }").unwrap();
let main = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
assert_eq!(
program.functions[main].signature.effects,
Default::default()
);
assert_eq!(
build::runtime_features(&program, &program.reachable([main])),
["calendar"]
);
let clocks = linked("main() (value: Int) { value = dever.time.unixMillis() }").unwrap();
let main = clocks
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
assert!(clocks.functions[main].signature.effects.clock);
assert!(!clocks.functions[main].signature.effects.timer);
assert!(!clocks.functions[main].signature.effects.suspend);
assert!(build::runtime_features(&clocks, &clocks.reachable([main])).is_empty());
for (source, code) in [
("main() (value: DateTime) { value = 0 }", "C005"),
(
"main(value: DateTime) (answer: DateTime) { answer = dever.time.add(value, 1) }",
"C005",
),
(
"main(value: DateTime) (answer: Bool) { answer = value < value }",
"C005",
),
(
"main() (value: DateTime) pure { value = dever.time.now() }",
"C013",
),
("main() () { value = dever.system.timeDuration(1) }", "C006"),
(
"main() () { lost = result(dever.time.parseDate(\"bad\")) }",
"C012",
),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
+116
View File
@@ -0,0 +1,116 @@
use super::checked;
#[test]
fn unconstrained_parameters_do_not_expand_clause_products() {
let inputs = (0..40)
.map(|position| format!("flag{position}: Bool"))
.collect::<Vec<_>>()
.join(", ");
let body = (0..40)
.map(|position| format!("flag{position}"))
.collect::<Vec<_>>()
.join(" or ");
let ordinary = format!("choose({inputs}) (answer: Bool) {{ answer = {body} }}\n");
assert!(checked(&ordinary).is_ok());
let clause = |pattern: &str| {
format!("choose(test: {pattern}, {inputs}) (answer: Bool) {{ answer = {body} }}\n")
};
assert!(checked(&(clause("true") + &clause("false"))).is_ok());
assert_eq!(checked(&clause("true")).unwrap_err().code, "C009");
assert_eq!(
checked(&(clause("Bool") + &clause("false")))
.unwrap_err()
.code,
"C009"
);
assert!(checked("type State { One\n Two }\nvalue(input: State, optional: Bool?) (answer: Bool) { answer = optional == null }").is_ok());
}
#[test]
fn choice_coverage_and_payload_contracts() {
let prefix = "type State { Idle\n Working(value: Int) }\n";
let valid = "read(value: State.Idle) (answer: Int) { answer = 0 }\nread(value: State.Working(number)) (answer: Int) { answer = number }";
assert!(checked(&(prefix.to_owned() + valid)).is_ok());
for (body, code) in [
(
"read(value: State.Idle) (answer: Int) { answer = 0 }",
"C009",
),
(
"read(value: State) (answer: Int) { answer = 0 }\nread(value: State.Idle) (answer: Int) { answer = 1 }",
"C009",
),
(
"read(value: State.Working) (answer: Int) { answer = 0 }",
"C005",
),
(
"read(value: State.Working(first, second)) (answer: Int) { answer = first }",
"C005",
),
(
"read(value: State.Working(value)) (answer: Int) { answer = 0 }",
"C009",
),
(
"main() (answer: State) { answer = State.Working() }",
"C005",
),
(
"main() (answer: State) { answer = State.Working(true) }",
"C005",
),
(
"read(value: State) (answer: Int) { answer = value.value }",
"C005",
),
] {
let error = checked(&(prefix.to_owned() + body)).unwrap_err();
assert_eq!(error.code, code, "{body}: {error:?}");
}
let wildcard = "read(value: State.Working(_)) (answer: Int) { answer = 1 }\nread(value: other) (answer: Int) { answer = 0 }";
assert!(checked(&(prefix.to_owned() + wildcard)).is_ok());
assert_eq!(
checked("type Cycle { Next(value: Cycle) }")
.unwrap_err()
.code,
"C007"
);
assert_eq!(
checked("type State { Item(value: Row) }\ntype Row { state: State }")
.unwrap_err()
.code,
"C007"
);
}
#[test]
fn nullable_domains_and_complements_are_order_independent() {
for clauses in [
"read(value: Text) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }",
"read(value: null) (answer: Int) { answer = -1 }\nread(value: Text) (answer: Int) { answer = length(value) }",
"read(value: other) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }\nread(value: \"x\") (answer: Int) { answer = 1 }",
] {
assert!(checked(clauses).is_ok(), "{clauses}");
}
for clauses in [
"read(value: Text?) (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
"read(value: \"x\") (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
] {
assert_eq!(checked(clauses).unwrap_err().code, "C009");
}
assert!(checked("equal(value: Int?) (answer: Bool) { answer = value == null or null == value or value == 1 or 1 == value }").is_ok());
assert!(checked("number() (answer: Decimal?) { answer = 1 }").is_ok());
assert!(checked("number() (answer: Float?) { answer = 1.5 + 2.5 }").is_ok());
assert!(checked("values() (answer: List<Int>?) { answer = [] }").is_ok());
}
#[test]
fn payload_bindings_do_not_shadow_inputs_or_outputs() {
for binding in ["value", "answer"] {
let text = format!(
"type State {{ Item(number: Int) }}\nread(value: State.Item({binding})) (answer: Int) {{ answer = 1 }}"
);
assert_eq!(checked(&text).unwrap_err().code, "C002");
}
}
+179
View File
@@ -0,0 +1,179 @@
use super::checked;
const HANDLERS: &str = "double(value: Int) (answer: Int) { answer = value * 2 }\npositive(value: Int) (answer: Bool) { answer = value > 0 }\nadd(value: Int, state: Int) (next: Int) { next = value + state }\nstop(value: Int, state: Int) (next: Int, stop: Bool) { next = value + state\n stop = next >= 3 }\n";
const ERRORS: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\nidentity(value: Result) (answer: Result) { answer = value }\nhandled(value: Result) (answer: Bool) recover(\"optional probe failure is explicitly discarded\") { answer = true }\ncontext(value: Int, state: Result) () recover(\"optional context probe is discarded\") {}\nkeep(value: Int, state: Result) (next: Result) { next = state }\nconsume(value: Result, state: Int) (next: Int) recover(\"count failed probes\") { next = state + 1 }\npartial(value: Result, state: Int) (next: Int, stop: Bool) recover(\"count failed probes\") { next = state + 1\n stop = true }\n";
#[test]
fn checks_collection_handler_types_and_cycles() {
for body in [
"main() (answer: List<Int>) { answer = each(double, []) }",
"main() (answer: List<Int>) { answer = filter(positive, [1, -1]) }",
"main() (answer: Int?) { answer = find(positive, []) }",
"main() (answer: Int) { answer = sum(double, []) }",
"main() (answer: Float) { answer = sum([]) }",
"main() (answer: Decimal?) { answer = sum([]) }",
"main() (answer: Int) { answer = reduce(add, [], 10) }",
"main() (answer: Int) { answer = reduceUntil(stop, [1, 2, 3], 0) }",
] {
let result = checked(&(HANDLERS.to_owned() + body));
assert!(result.is_ok(), "{body}: {result:?}");
}
for body in [
"main() (answer: List<Int>) { answer = each(positive, [1]) }",
"main() (answer: List<Int>) { answer = filter(double, [1]) }",
"main() (answer: Int?) { answer = find(positive, [\"one\"]) }",
"main() (answer: Int) { answer = sum(positive, [1]) }",
"main() (answer: Int) { answer = reduce(add, [1], true) }",
"main() (answer: Int) { answer = reduceUntil(add, [1], 0) }",
"main() (answer: Int) { answer = reduce(add, [1], 0, 1) }",
"main() (answer: List<Int>) { double = 2\n answer = each(double, [1]) }",
"main() () { handler = double }",
] {
assert!(checked(&(HANDLERS.to_owned() + body)).is_err(), "{body}");
}
assert_eq!(
checked("cycle(value: Int) (answer: Int) { answer = sum(cycle, [value]) }")
.unwrap_err()
.code,
"C007"
);
assert_eq!(checked("first(value: Int) (answer: Int) { answer = sum(second, [value]) }\nsecond(value: Int) (answer: Int) { answer = first(value) }").unwrap_err().code, "C007");
}
#[test]
fn collections_preserve_error_obligations() {
for body in [
"main(values: List<Result>) (answer: Int) { copies = each(identity, values)\n answer = length(copies) }",
"main(values: List<Result>) (answer: Result?) { answer = find(handled, values) }",
"main(values: List<Result>) (answer: Int) { answer = reduceUntil(partial, values, 0) }",
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\"), Result.Failed(\"two\")], 0) }",
"main(state: Result) () { each(context, [], state) }",
"main(values: List<Int>, state: Result) () { each(context, values, state) }",
"main(state: Result) (answer: Int) { kept = reduce(keep, [], state)\n answer = 1 }",
"main() (answer: List<Result>) { lost = Result.Failed(\"lost\")\n answer = each(identity, []) }",
] {
let result = checked(&(ERRORS.to_owned() + body));
assert_eq!(result.unwrap_err().code, "C012", "{body}");
}
for body in [
"main(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }",
"main(values: List<Result>) (answer: Int) { answer = reduce(consume, values, 0) }",
"main(state: Result) () { each(context, [1], state) }",
"main() (answer: Result?) { answer = find(handled, [Result.Failed(\"one\")]) }",
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\")], 0) }",
"main(state: Result) (answer: Result) { answer = reduce(keep, [], state) }",
] {
let result = checked(&(ERRORS.to_owned() + body));
assert!(result.is_ok(), "{body}: {result:?}");
}
}
#[test]
fn collection_error_summaries_survive_calls_and_repeated_state() {
let prefix = ERRORS.to_owned()
+ "copy(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }\nreplace(value: Result, state: Result) (next: Result) recover(\"last probe replaces previous probe\") { next = value }\n";
for body in [
"main(values: List<Result>) (answer: Int) { copies = copy(values)\n answer = length(copies) }",
"main(values: List<Result>, state: Result) (answer: Int) { last = reduce(replace, values, state)\n answer = 1 }",
"main(state: Result) () { copies = copy([])\n count = length(copies) }",
] {
assert_eq!(
checked(&(prefix.clone() + body)).unwrap_err().code,
"C012",
"{body}"
);
}
let source = prefix
+ "main(values: List<Result>, state: Result) (answer: Result) { answer = reduce(replace, values, state) }";
assert!(checked(&source).is_ok());
}
#[test]
fn optional_traversal_cannot_transfer_unvisited_obligations() {
let prefix = ERRORS.to_owned()
+ "takeContext(value: Int, context: Result) (answer: Result) { answer = context }\nfirst(value: Result, state: Result) (next: Result, stop: Bool) recover(\"replace previous state\") { next = value\n stop = true }\n";
for body in [
"main(values: List<Int>, state: Result) (answer: List<Result>) { answer = each(takeContext, values, state) }",
"main(values: List<Result>) (answer: Result) { answer = reduceUntil(first, values, Result.Ready(0)) }",
] {
assert_eq!(
checked(&(prefix.clone() + body)).unwrap_err().code,
"C012",
"{body}"
);
}
assert!(checked(&(prefix + "main(state: Result) (answer: List<Result>) { answer = each(takeContext, [1], state) }")).is_ok());
}
#[test]
fn replacing_record_fields_still_checks_discarded_failures() {
let source = ERRORS.to_owned()
+ "type Row { result: Result\n count: Int }\nreplace(value: Row) (answer: Row) { answer = value\n answer.result = Result.Ready(1)\n answer.count = 0 }";
assert_eq!(checked(&source).unwrap_err().code, "C012");
}
#[test]
fn known_length_map_builds_reserve_once_and_update_in_place() {
for (kind, item, value, sequence) in [
("Bytes", "Int", "value", "values"),
("List<Int>", "Int", "value", "values"),
(
"Map<Int, Int>",
"MapEntry<Int, Int>",
"value.value",
"entries(values)",
),
] {
let text = format!(
"insert(value: {item}, state: Map<Int, Int>) (next: Map<Int, Int>) {{ next = put(state, length(state), {value}) }}\nmain(values: {kind}) (answer: Map<Int, Int>) {{ answer = reduce(insert, {sequence}, {{}}) }}"
);
let source = dever::syntax::source::Source::new(0, "case.dever", text);
let program = checked(&source.text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
let main = generated.split("pub fn function1(").nth(1).unwrap();
let main = main.split("pub fn ").next().unwrap();
assert!(
main.contains("Map::with_capacity(sequence.len())"),
"{main}"
);
assert!(main.contains("state.insert("), "{main}");
assert!(!main.contains("state.clone()"), "{main}");
assert!(!main.contains(".to_owned()"), "{main}");
}
}
#[test]
fn record_reductions_keep_a_single_growing_buffer() {
let text = "type State { values: List<Int>\n count: Int }\nstep(value: Int, state: State) (next: State) { values = append(state.values, value)\n next = State { values = values\n count = state.count + 1 } }\nmain(input: Bytes) (answer: State) { answer = reduce(step, input, State { values = []\n count = 0 }) }";
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
let program = checked(text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
let main = generated.split("pub fn function1(").nth(1).unwrap();
let main = main.split("\npub ").next().unwrap();
assert!(!main.contains(".clone()"), "{main}");
let iteration = main.split("for byte in sequence").nth(1).unwrap();
let iteration = iteration.split("Type0 {").next().unwrap();
assert!(!iteration.contains(".reserve("), "{main}");
assert!(iteration.contains(".push(element)"), "{main}");
}
#[test]
fn map_selection_copies_only_matching_entries() {
for (operation, result) in [
("filter", "List<MapEntry<Text, Text>>"),
("find", "MapEntry<Text, Text>?"),
] {
let text = format!(
"matches(entry: MapEntry<Text, Text>, target: Text) (answer: Bool) {{ answer = entry.value == target }}\nmain(values: Map<Text, Text>, target: Text) (answer: {result}) {{ answer = {operation}(matches, entries(values), target) }}"
);
let source = dever::syntax::source::Source::new(0, "case.dever", text.clone());
let program = checked(&text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
let main = generated.split("pub fn function1(").nth(1).unwrap();
let main = main.split("\npub ").next().unwrap();
let iteration = main.split("for (key, value) in sequence").nth(1).unwrap();
let (inspection, selected) = iteration.split_once("if ").unwrap();
assert!(!inspection.contains(".clone()"), "{main}");
assert!(selected.contains("(key.clone(), value.clone())"), "{main}");
}
}
+95
View File
@@ -0,0 +1,95 @@
use dever::{
compiler::{check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
(
"dever.crypto",
include_str!("../../../lib/dever/crypto.dever"),
),
("dever.time", include_str!("../../../lib/dever/time.dever")),
("dever.task", include_str!("../../../lib/dever/task.dever")),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (_, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("library{index}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn secrets_cannot_be_compared_converted_or_hidden_in_error_payloads() {
for source in [
"main(value: Secret) (answer: Bool) { answer = value == value }",
"main(values: List<Secret>) (answer: Bool) { answer = values == values }",
"main(value: Secret) (answer: Int) { answer = length(value) }",
"main(value: Secret) (answer: Text) { answer = value }",
"type Invalid { error Failed(value: Secret) }\nmain() () {}",
"type Inner { key: Secret }\ntype Invalid { error Failed(values: List<Inner>) }\nmain() () {}",
"main(values: Map<Secret, Int>) () {}",
] {
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
}
let error = linked("main() () { value = secret(\"password\") }").unwrap_err();
assert_eq!(error.code, "C006");
assert!(error.message.contains("compiler-owned Test source"));
assert!(linked("main(value: Secret) (answer: Secret?) { answer = value }").is_ok());
}
#[test]
fn crypto_randomness_and_password_work_keep_effect_boundaries() {
for source in [
"main() () pure { value = dever.crypto.token(16) }",
"main(password: Secret) (hash: Text) { dever.task.sleep(0)\n hash = dever.crypto.passwordHash(password) }",
"main(password: Secret, hash: Text) (valid: Bool) { dever.task.sleep(0)\n valid = dever.crypto.passwordVerify(password, hash) }",
] {
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
}
assert!(linked("main(password: Secret) (hash: Text) { hash = blocking(dever.crypto.passwordHash(password)) }").is_ok());
assert!(linked("main(key: Secret, bytes: Bytes) (hash: Bytes) pure { hash = dever.crypto.hmacSha256(key, bytes) }").is_ok());
assert_eq!(
linked("main() () { value = dever.system.cryptoToken(16) }")
.unwrap_err()
.code,
"C006"
);
assert_eq!(
linked("main() () { lost = result(dever.crypto.token(0)) }")
.unwrap_err()
.code,
"C012"
);
}
#[test]
fn app_outputs_reject_nested_secret_types() {
for source in [
"read() (value: Secret?) { value = null }",
"type Envelope { values: List<Secret> }\nread() (value: Envelope) { value = Envelope { values = [] } }",
] {
let module = dever::syntax::parse(&Source::new(0, "app.dever", source.into())).unwrap();
let error = check::check(vec![module], &["shop.account.app".into()]).unwrap_err();
assert_eq!(error.code, "C006");
assert!(error.message.contains("Secret"));
}
}
+112
View File
@@ -0,0 +1,112 @@
use super::{ERROR, packages};
#[test]
fn text_policy_is_a_single_model_modifier_and_defaults_use_the_normalized_length() {
use dever::compiler::model::persistence::{TextMode, Value};
let program = packages(&[("demo.account.model", "type Account { name: Text(1, 2) lower default \" Ä \"\ncode: Text(2) upper default \" ß \"\nbody: Text raw default \" a \"\nnote: Text? }")]).unwrap();
let columns = &program.models[0].columns;
for (column, mode, expected) in [
(2, TextMode::Lower, "ä"),
(3, TextMode::Upper, "SS"),
(4, TextMode::Raw, " a "),
] {
assert_eq!(columns[column].text, mode);
assert!(matches!(&columns[column].default, Some(Value::Text(value)) if value == expected));
}
assert_eq!(columns[5].text, TextMode::Trim);
for field in [
"name: Text raw lower",
"name: Text lower upper",
"name: Text raw raw",
"name: Int lower",
"name: List<Text> raw",
"name: Text(1, 2) default \" \"",
"name: Text(1) upper default \"ß\"",
"name: Text trim",
] {
assert!(
packages(&[("demo.account.model", &format!("type Account {{ {field} }}"))]).is_err(),
"{field}"
);
}
assert!(packages(&[("demo.account.domain", "type Input { name: Text raw }")]).is_err());
let seeded = packages(&[(
"demo.account.model",
"type Account { name: Text(1, 2) lower unique }\nseed { { name = \" Ä \" } }",
)])
.unwrap();
assert!(matches!(&seeded.models[0].seeds[0].values[0].1, Value::Text(value) if value == "ä"));
}
#[test]
fn model_choice_options_preserve_labels_and_order() {
let program = packages(&[
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { status: Status }"),
("demo.account.app", "options() (values: Map<model.Status, Text>) { values = model.Status.options }"),
]).unwrap();
assert!(!program.functions[0].signature.effects.database);
assert!(packages(&[("main", "type Status { Active }\npublic main() (values: Map<Text, Text>) { values = Status.options }")]).is_err());
}
#[test]
fn checked_defaults_and_generated_columns_keep_static_types() {
let program = packages(&[
("dever.database", ERROR),
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { uid: Uuid generated unique\nstatus: Status default Status.Active\namount: Decimal(5, 2) default -12.30\nnote: Text? default null\nname: Text(1, 8) default \"雪\" }"),
("demo.account.app", "create() (id: model.id) { row = model.create({})\nid = row.id }"),
("demo.account.api", "cmd create = app.create"),
]).unwrap();
let columns = &program.models[0].columns;
assert!(columns[2].generated);
assert_eq!(
columns[3].choice.as_ref().unwrap(),
&[
("Active".into(), "启用".into()),
("Disabled".into(), "禁用".into())
]
);
assert!(
matches!(&columns[4].default, Some(dever::compiler::model::persistence::Value::Text(value)) if value == "098770")
);
}
#[test]
fn invalid_defaults_and_generated_writes_fail_before_database_access() {
for field in [
"value: Decimal(5, 2) default 0.001",
"value: Decimal(5, 2) default 1000",
"value: Text(2) default \"三字符\"",
"value: Int default 1.1",
"value: Int default 9223372036854775808",
"value: Float default 1e999",
"value: Bool default null",
"value: Uuid default \"broken\"",
"value: Date default \"2025-02-29\"",
"value: Time default \"24:00:00\"",
"value: Json default \"{\\\"x\\\":1,\\\"x\\\":2}\"",
"value: Int generated",
"value: Uuid? generated",
"value: Uuid generated default \"00112233-4455-6677-8899-aabbccddeeff\"",
] {
let model = format!("type Account {{ {field} }}");
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
assert_eq!(error.code, "C014", "{field}: {error:?}");
}
for body in [
"row = model.create({ uid = uid })",
"row = model.update(id, { uid = uid })",
"row = model.get(id)\nrow.uid = uid",
] {
let app = format!(
"write(id: model.id, uid: Uuid) (value: model.id) {{ {body}\nvalue = row.id }}"
);
let error = packages(&[
("dever.database", ERROR),
("demo.account.model", "type Account { uid: Uuid generated }"),
("demo.account.app", &app),
])
.unwrap_err();
assert!(matches!(error.code, "C014" | "C005"), "{body}: {error:?}");
assert!(error.message.contains("read-only"), "{error:?}");
}
}
+161
View File
@@ -0,0 +1,161 @@
use super::package::packages;
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
mod defaults;
mod native;
mod privacy;
mod query;
mod relation;
mod scalar;
mod schema;
pub(super) const ERROR: &str = "public type Error { error Pool(message: Text)\nerror PoolExhausted(message: Text)\nerror Connection(message: Text)\nerror Timeout(message: Text)\nerror Cancelled(message: Text)\nerror Database(message: Text)\nerror Constraint(message: Text)\nerror NotFound(message: Text)\nerror InvalidData(message: Text)\nerror Migration(message: Text) }";
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
packages(&[
("dever.database", ERROR),
(
"demo.account.model",
"type Account { name: Text(1, 64) unique\nnote: Text? }",
),
("demo.account.app", app),
("demo.account.api", api),
])
}
#[test]
fn model_ids_queries_and_failures_keep_static_identity() {
let program = checked(
"get(id: model.id) (account: model.Account) { account = model.get(id) }",
"cmd get = app.get",
)
.unwrap();
let function = &program.functions[0].signature;
assert!(function.effects.database);
assert!(!function.effects.database_write);
assert_eq!(function.failures.len(), 10);
assert_eq!(program.models.len(), 1);
assert!(program.models[0].columns[2].unique);
assert_eq!(program.models[0].columns[2].limits, Some((1, 64)));
assert_eq!(
checked(
"get(id: Int) (account: model.Account) { account = model.get(id) }",
"cmd get = app.get"
)
.unwrap_err()
.code,
"C005"
);
let app = "read(value: model.Account) (id: model.id) { value.id = value.id\nid = value.id }";
assert_eq!(
checked(app, "cmd read = app.read").unwrap_err().code,
"C014"
);
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public get create = app.create").unwrap_err().code, "C014");
}
#[test]
fn transactions_reject_unrelated_work_and_role_bypasses() {
let crypto = "public type HashResult { Ready(value: Text)\nerror Failed(message: Text) }\npublic passwordHash(secret: Secret) (hash: Text) { hash = hashValue(dever.system.cryptoPasswordHash(secret)) }\nhashValue(result: HashResult.Ready(value)) (hash: Text) { hash = value }\nhashValue(result: HashResult.Failed(message)) (hash: Text) { fail(result) }";
let blocked = packages(&[
("dever.database", ERROR),
("dever.crypto", crypto),
("demo.account.model", "type Account { name: Text }"),
(
"demo.account.app",
"transaction create(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = model.create({ name = hash }) }",
),
("demo.account.api", "cmd create = app.create"),
]);
assert_eq!(blocked.unwrap_err().code, "C014");
let staged = packages(&[
("dever.database", ERROR),
("dever.crypto", crypto),
("demo.account.model", "type Account { name: Text }"),
("demo.account.app", "transaction persist(name: Text) (account: model.Account) { account = model.create({ name = name }) }\ncreate(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = persist(hash) }"),
]).unwrap();
let staged = &staged
.functions
.iter()
.find(|function| function.signature.name == "create")
.unwrap()
.signature
.effects;
assert!(staged.password);
assert!(!staged.unscoped_write);
let app = "transaction create(name: Text) (account: model.Account) { account = model.create({ name = name }) }";
assert!(checked(app, "long cmd create = app.create").is_ok());
assert!(checked(app, "public long post create = app.create").is_ok());
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "long cmd create = app.create").unwrap_err().code, "C014");
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public long post create = app.create").unwrap_err().code, "C014");
assert!(
checked(
"read(value: model.Page) (page: model.Page) { value.page = 2\npage = value }",
"cmd read = app.read"
)
.is_ok()
);
assert_eq!(
checked(
"transaction empty(value: Int) (answer: Int) { answer = value }",
"cmd empty = app.empty"
)
.unwrap_err()
.code,
"C014"
);
assert_eq!(checked("read() (total: Int) { total = model.count() }\ntransaction spawn() (total: Int) { work = run(read())\ntotal = wait(work) }", "cmd spawn = app.spawn").unwrap_err().code, "C014");
assert_eq!(
packages(&[
("dever.database", ERROR),
("demo.account.model", "type Account { name: Text }"),
(
"demo.account.domain",
"read() (total: Int) { total = model.count() }"
)
])
.unwrap_err()
.code,
"C006"
);
assert_eq!(
packages(&[("main", "type Value { name: Text(64) }")])
.unwrap_err()
.code,
"C014"
);
assert_eq!(
packages(&[("main", "type Value { name: Text unique }")])
.unwrap_err()
.code,
"C014"
);
}
#[test]
fn model_query_blocks_reject_dynamic_fields_and_invalid_options() {
for query in [
"{ where = missing == name }",
"{ where = name }",
"{ order = [missing.asc] }",
"{ size = 1, size = 2 }",
"{ size = name }",
] {
let app = format!("search(name: Text) (page: model.Page) {{ page = model.list({query}) }}");
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
}
assert_eq!(
packages(&[(
"demo.account.model",
"database one\ndatabase two\ntype Account { name: Text }"
)])
.unwrap_err()
.code,
"C002"
);
assert_eq!(
packages(&[("demo.account.model", "type Account { name: Text(65, 64) }")])
.unwrap_err()
.code,
"C014"
);
}
+200
View File
@@ -0,0 +1,200 @@
use super::{ERROR, packages};
fn checked(
sqlite: &str,
postgres: &str,
entry: &str,
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
let model = format!(
"type Account {{ name: Text }}\nsql lookup(name: Text) (row: app.Summary?) {{ sqlite = {sqlite:?}\npostgres = {postgres:?} }}"
);
packages(&[
("dever.database", ERROR),
("demo.account.model", &model),
(
"demo.account.app",
"type Summary { name: Text }\nread(name: Text) (value: Text) { row = model.lookup(name)\nvalue = text(row) }\ntext(row: Summary?) (value: Text) { value = \"present\" }",
),
("demo.account.api", entry),
])
}
#[test]
fn native_sql_proves_only_owned_static_projections_readonly() {
let sqlite = "SELECT name FROM demo_account_model WHERE name = ?1 ORDER BY id DESC";
let postgres = sqlite.replace('?', "$");
let program = checked(sqlite, &postgres, "public get read = app.read").unwrap();
assert!(program.models[0].sql[0].readonly);
let complex = "SELECT max(name) AS name FROM demo_account_model WHERE name = ?1";
assert!(checked(complex, &complex.replace('?', "$"), "cmd read = app.read").is_ok());
assert!(
checked(
complex,
&complex.replace('?', "$"),
"public get read = app.read"
)
.is_err()
);
assert!(
checked(
sqlite,
"SELECT name FROM other_table WHERE name = $1",
"public get read = app.read"
)
.is_err()
);
}
#[test]
fn native_sql_accepts_readonly_alias_count_and_parameterized_paging() {
for sql in [
"SELECT name AS label FROM demo_account_model WHERE name = ?1",
"SELECT COUNT(*) AS total FROM demo_account_model WHERE name = ?1",
"SELECT name AS label FROM demo_account_model ORDER BY id DESC LIMIT ?1 OFFSET ?1",
] {
assert!(
checked(sql, &sql.replace('?', "$"), "public get read = app.read")
.unwrap()
.models[0]
.sql[0]
.readonly
);
}
for sql in [
"SELECT name AS label, unknown() FROM demo_account_model WHERE name = ?1",
"SELECT count(unknown()) FROM demo_account_model WHERE name = ?1",
"SELECT name FROM demo_account_model LIMIT ?1 OFFSET unknown()",
] {
assert!(
checked(sql, &sql.replace('?', "$"), "public get read = app.read").is_err(),
"{sql}"
);
}
}
#[test]
fn native_sql_rejects_binding_and_statement_escape() {
for sql in [
"SELECT name FROM demo_account_model WHERE name = ?2",
"SELECT name FROM demo_account_model WHERE name = :name",
"SELECT name FROM demo_account_model WHERE name = ?1; DELETE FROM demo_account_model",
"SELECT name FROM _dever_role WHERE name = ?1",
"SELECT name FROM \"_dever_role\" WHERE name = ?1",
"SELECT name FROM demo_account_model WHERE name = '?1'",
"SELECT name FROM demo_account_model WHERE name = ?1 /*",
"SELECT name FROM demo_account_model, other_table WHERE name = ?1",
"SELECT name FROM demo_account_model.other_table WHERE name = ?1",
"SELECT name FROM demo_account_model JOIN other_table ON name = ?1",
] {
assert!(
checked(sql, &sql.replace('?', "$"), "cmd read = app.read").is_err(),
"{sql}"
);
}
}
#[test]
fn native_sql_accepts_same_domain_app_view_and_rejects_constrained_results() {
let model = "type Account { name: Text }\nsql lookup(name: Text) (row: app.Summary?) { sqlite = \"SELECT name FROM demo_account_model WHERE name = ?1\"\npostgres = \"SELECT name FROM demo_account_model WHERE name = $1\" }";
let app = "type Summary { name: Text }\nread(name: Text) (row: Summary?) { row = model.lookup(name) }";
assert!(
packages(&[
("dever.database", ERROR),
("demo.account.model", model),
("demo.account.app", app),
("demo.account.api", "public get read = app.read")
])
.is_ok()
);
for record in [
"type Extra { value: Int }",
"type Extra { value: Secret }",
"type Extra { value: List<Int> }",
] {
let model = format!("type Account {{ name: Text }}\n{record}");
assert!(packages(&[("demo.account.model", &model)]).is_err());
}
}
#[test]
fn native_sql_records_stay_private_and_all_joined_models_are_dependencies() {
let model = "type Account { name: Text }\ntype Summary { name: Text }\nsql joined() (row: Summary) { sqlite = \"SELECT demo_account_model.name FROM demo_account_model, demo_profile_model\"\npostgres = \"SELECT demo_account_model.name FROM demo_account_model JOIN demo_profile_model ON true\" }";
let app = "read() (name: Text) { row = model.joined()\nname = row.name }";
let mut program = packages(&[
("dever.database", ERROR),
("demo.account.model", model),
("demo.profile.model", "type Profile { name: Text }"),
("demo.account.app", app),
("demo.account.api", "cmd read = app.read"),
])
.unwrap();
assert_eq!(program.models[0].sql[0].dependencies, [0, 1]);
program.commands.clear();
assert!(
program
.functions
.iter()
.all(|function| !function.signature.transaction)
);
let generated = dever::compiler::emit::generate(&program, &[], &Default::default());
assert!(generated.contains("registry.check_transactions(&[&[0,1]])"));
let leaked = "read() (row: model.Summary) { row = model.joined() }";
assert!(
packages(&[
("dever.database", ERROR),
("demo.account.model", model),
("demo.profile.model", "type Profile { name: Text }"),
("demo.account.app", leaked),
("demo.account.api", "cmd read = app.read")
])
.unwrap_err()
.message
.contains("private type")
);
}
#[test]
fn native_sql_reuses_model_columns_but_rejects_unproved_projection_bounds() {
let model = "type Account { name: Text(8) }\nsql snapshot() (row: Account) { sqlite = \"SELECT id, created_at, name FROM demo_account_model\"\npostgres = \"SELECT id, created_at, name FROM demo_account_model\" }";
assert!(
packages(&[
("dever.database", ERROR),
("demo.account.model", model),
(
"demo.account.app",
"read() (name: Text) { row = model.snapshot()\nname = row.name }"
),
("demo.account.api", "cmd read = app.read")
])
.is_ok()
);
for field in ["name: Text(8)", "name: Text unique", "name: Text index"] {
let model = format!(
"type Account {{ name: Text }}\ntype Summary {{ {field} }}\nsql summary() (row: Summary) {{ sqlite = \"SELECT name FROM demo_account_model\"\npostgres = \"SELECT name FROM demo_account_model\" }}"
);
assert!(
packages(&[("demo.account.model", &model)]).is_err(),
"{field}"
);
}
}
#[test]
fn quoted_sql_fields_are_identifiers_and_do_not_create_fake_table_dependencies() {
let sql = "SELECT \"from\" FROM \"demo_account_model\" WHERE \"from\" = ?1";
let model = format!(
"type Account {{ from: Text }}\nsql lookup(value: Text) (row: app.View) {{ sqlite = {sql:?}\npostgres = {:?} }}",
sql.replace('?', "$")
);
let program = packages(&[
("dever.database", ERROR),
("demo.account.model", &model),
(
"demo.account.app",
"type View { from: Text }\nread(value: Text) (row: View) { row = model.lookup(value) }",
),
("demo.account.api", "public get read = app.read"),
])
.unwrap();
assert!(program.models[0].sql[0].readonly);
}
+113
View File
@@ -0,0 +1,113 @@
use super::{ERROR, packages};
fn check(app: &str) -> Result<super::Program, super::Diagnostic> {
packages(&[
("dever.database", ERROR),
(
"demo.account.model",
"type Account { name: Text\nprivate token: Text }",
),
("demo.account.app", app),
])
}
#[test]
fn private_model_snapshots_cannot_cross_app_signatures() {
for kind in [
"model.Account",
"model.Account?",
"List<model.Account>",
"model.Page",
"Map<Text, model.Account>",
"MapEntry<Text, model.Account>",
"AsyncStream<model.Account>",
"Related<model.Account>",
] {
let body = format!("read(value: {kind}) (answer: {kind}) {{ answer = value }}");
let error = check(&body).unwrap_err();
assert_eq!(error.code, "C006", "{kind}: {error:?}");
assert!(error.message.contains("private Model"), "{error:?}");
}
let error = check("read(id: model.id) (account: model.Account) { account = model.get(id) }")
.unwrap_err();
assert_eq!(error.code, "C006");
}
#[test]
fn private_models_cannot_hide_in_app_records_or_errors() {
for body in [
"type Wrapped { private value: model.Account }\nread(value: Wrapped) (answer: Wrapped) { answer = value }",
"type Problem { error Denied(account: model.Account) }\nread(id: model.id) () { account = model.get(id)\nfail(Problem.Denied(account)) }",
"read(action: handler(value: model.Account) ()) () {}",
] {
let error = check(body).unwrap_err();
assert_eq!(error.code, "C006", "{error:?}");
assert!(error.message.contains("private Model"), "{error:?}");
}
}
#[test]
fn opaque_public_records_remain_legal_but_private_fields_stay_off_wire() {
let opaque = "type Session { private token: Text\nlabel: Text }\nread(value: Session) (answer: Session) { answer = value }";
assert!(check(opaque).is_ok());
let error = packages(&[
("demo.account.app", opaque),
("demo.account.api", "cmd read = app.read"),
])
.unwrap_err();
assert_eq!(error.code, "C006");
assert!(error.message.contains("private fields"), "{error:?}");
assert!(check("type View { name: Text }\nread(id: model.id) (view: View) { account = model.get(id)\nview = View { name = account.name } }").is_ok());
let hidden = [
("demo.account.domain", "type State { value: Text }"),
(
"demo.account.app",
"type Session { private state: domain.State\nlabel: Text }",
),
];
let mut files = hidden.to_vec();
files.push((
"demo.other.app",
"pass(value: demo.account.Session) (answer: demo.account.Session) { answer = value }",
));
assert!(packages(&files).is_ok());
files.pop();
files.push((
"demo.other.app",
"read(value: demo.account.Session) (answer: Text) { answer = value.state.value }",
));
assert_eq!(packages(&files).unwrap_err().code, "C006");
}
#[test]
fn private_model_data_propagates_through_related_cycles_without_blocking_ids() {
let models = [
("dever.database", ERROR),
(
"demo.people.model.team",
"type Team { name: Text }\nrelation users = demo.people.model.user.team_id",
),
(
"demo.people.model.user",
"type User { private token: Text\nteam_id: model.team.id }",
),
];
for kind in [
"model.team.Team",
"Related<model.team.Team>",
"List<model.team.Team?>",
] {
let app = format!("pass(value: {kind}) (answer: {kind}) {{ answer = value }}");
let mut files = models.to_vec();
files.push(("demo.people.app", &app));
let error = packages(&files).unwrap_err();
assert_eq!(error.code, "C006", "{error:?}");
assert!(error.message.contains("private Model"), "{error:?}");
}
let mut files = models.to_vec();
files.push((
"demo.people.app",
"pass(value: model.user.id) (answer: model.user.id) { answer = value }",
));
assert!(packages(&files).is_ok());
}
+118
View File
@@ -0,0 +1,118 @@
use super::checked;
#[test]
fn conditional_writes_require_static_predicates_and_write_entries() {
let update = "change(name: Text) (count: Int) { count = model.update({ where = name == name }, { note = name }) }";
assert!(checked(update, "cmd change = app.change").is_ok());
assert_eq!(
checked(update, "public get change = app.change")
.unwrap_err()
.code,
"C014"
);
for expression in [
"model.update({}, { note = name })",
"model.update({ where = name == name, order = [id.asc] }, { note = name })",
"model.update({ where = name == name }, {})",
"model.update({ where = name == name }, { note = name, note = name })",
"model.delete({})",
"model.delete({ where = name == name, size = 1 })",
] {
let source = format!("change(name: Text) (count: Int) {{ count = {expression} }}");
assert!(
checked(&source, "cmd change = app.change").is_err(),
"{expression}"
);
}
}
#[test]
fn cursor_checks_snapshot_type_order_and_options() {
for query in ["", "{ after = null, size = 2, order = [name.asc] }"] {
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
assert!(checked(&app, "cmd search = app.search").is_ok(), "{query}");
}
for query in [
"{ after = 1 }",
"{ size = \"two\" }",
"{ order = [note.asc] }",
"{ order = [name.asc, name.desc] }",
"{ page = 1 }",
] {
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
}
}
#[test]
fn query_predicates_require_static_fields_and_typed_bounded_values() {
let api = "cmd search = app.search";
for condition in [
"not contains(name, value) and in(name, [value, \"other\"])",
"between(name, value, \"雪\") or startsWith(note, value)",
"endsWith(name, value) and note == null",
] {
let app = format!(
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
);
assert!(checked(&app, api).is_ok(), "{condition}");
}
for condition in [
"in(name, [])",
"in(name, value)",
"in(name, [1])",
"contains(name)",
"contains(id, value)",
"contains(\"field\", value)",
"contains(missing, value)",
"between(name, value)",
"startsWith(name, 1)",
"unknown(name, value)",
] {
let app = format!(
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
);
assert!(checked(&app, api).is_err(), "accepted {condition}");
}
let items = vec!["value"; 101].join(",");
let app = format!(
"search(value: Text) (total: Int) {{ total = model.count({{ where = in(name, [{items}]) }}) }}"
);
assert!(checked(&app, api).is_err());
assert!(
checked(
"search(value: Text) (answer: Bool) { answer = contains(value, value) }",
api
)
.is_err()
);
}
#[test]
fn bulk_and_upsert_keep_field_contracts_and_unique_keys() {
for body in [
"total = model.createMany([])",
"total = model.createMany([{ name = \"one\" }, { name = \"two\", note = null }])",
"row = model.upsert({ name = \"one\" }, {}, { note = \"updated\" })\ntotal = length(row.name)",
] {
let app = format!("write() (total: Int) {{ {body} }}");
assert!(checked(&app, "cmd write = app.write").is_ok(), "{body}");
assert!(checked(&app, "public get write = app.write").is_err());
}
for body in [
"total = model.createMany([{}])",
"total = model.createMany([{ name = \"one\", name = \"two\" }])",
"total = model.createMany([{ id = 1, name = \"one\" }])",
"total = model.createMany([{ name = 7 }])",
"row = model.upsert({}, { name = \"one\" }, {})\ntotal = length(row.name)",
"row = model.upsert({ note = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
"row = model.upsert({ name = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
"row = model.upsert({ name = \"key\" }, {}, { id = 2 })\ntotal = length(row.name)",
] {
let app = format!("write() (total: Int) {{ {body} }}");
assert!(
checked(&app, "cmd write = app.write").is_err(),
"accepted {body}"
);
}
}
+58
View File
@@ -0,0 +1,58 @@
use super::{ERROR, packages};
fn check(
body: &str,
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
packages(&[
("dever.database", ERROR),
(
"demo.people.model.user",
"type User { name: Text }\nrelation posts = demo.people.model.post.author_id",
),
(
"demo.people.model.post",
"type Post { author_id: model.user.id\ntitle: Text }",
),
("demo.people.app", body),
("demo.people.api", "cmd read = app.read"),
])
}
#[test]
fn relations_are_static_and_propagate_all_models() {
let program = check(
"read() (total: Int) { page = model.user.list({ with = [posts] })\ntotal = page.total }",
)
.unwrap();
assert_eq!(program.models[0].relations.len(), 1);
let operation = &program.functions[0].clauses[0].body[0];
let dever::compiler::model::Statement::Assign { value, .. } = operation else {
panic!()
};
let dever::compiler::model::Node::Database(query, _) = &value.kind else {
panic!()
};
assert_eq!(query.models().collect::<Vec<_>>(), vec![0, 1]);
for option in [
"with = [posts, posts]",
"with = [posts.author]",
"with = [missing]",
] {
assert!(
check(&format!(
"read() (page: model.user.Page) {{ page = model.user.list({{ {option} }}) }}"
))
.is_err()
);
}
}
#[test]
fn streams_cannot_be_used_inside_transactions_or_load_relations() {
for body in [
"transaction read() (total: Int) { rows = model.user.stream()\nclose(rows)\ntotal = 0 }",
"read() (total: Int) { rows = model.user.stream({ with = [posts] })\nclose(rows)\ntotal = 0 }",
] {
assert!(check(body).is_err(), "{body}");
}
}
+42
View File
@@ -0,0 +1,42 @@
use super::{ERROR, packages};
#[test]
fn scalar_columns_keep_distinct_precision_and_query_bindings() {
let program = packages(&[
("dever.database", ERROR),
("demo.account.model", "type Account { amount: Decimal(34, 4)\nfee: Decimal(5, 2)?\nuid: Uuid unique\nday: Date\ntime: Time\nelapsed: Duration\ndocument: Json }"),
("demo.account.app", "search(amount: Decimal, fee: Decimal?) (page: model.Page) { page = model.list({ where = amount >= amount and fee == fee, order = [amount.asc] }) }"),
("demo.account.api", "cmd search = app.search"),
]).unwrap();
assert_eq!(program.models[0].columns[2].decimal, Some((34, 4)));
assert_eq!(program.models[0].columns[3].decimal, Some((5, 2)));
assert_eq!(program.models[0].columns[2].limits, None);
assert!(program.models[0].columns[4].unique);
}
#[test]
fn scalar_metadata_rejects_lossy_or_ambiguous_decimal_storage() {
for kind in [
"Decimal",
"Decimal(5)",
"Decimal(0, 0)",
"Decimal(35, 4)",
"Decimal(4, 5)",
"Decimal(4, 2, 1)",
] {
let model = format!("type Account {{ amount: {kind} }}");
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
assert_eq!(error.code, "C014", "{kind}: {error:?}");
}
for kind in ["Decimal(1, 0)", "Decimal(34, 34)", "Decimal(9, 2)?"] {
let model = format!("type Account {{ amount: {kind} }}");
packages(&[("demo.account.model", &model)]).unwrap();
}
for kind in ["List<Int>", "Id", "Secret"] {
let model = format!("type Account {{ value: {kind} }}");
assert!(
packages(&[("demo.account.model", &model)]).is_err(),
"{kind}"
);
}
}
+122
View File
@@ -0,0 +1,122 @@
use super::packages;
#[test]
fn storage_declarations_do_not_reserve_existing_function_names() {
for name in ["seed", "index", "unique", "migrate"] {
let source = format!("public {name}() (answer: Int) {{ answer = 1 }}");
packages(&[("main", &source)]).unwrap();
let source = format!("public {name}(value: Int) (answer: Int) {{ answer = value }}");
packages(&[("main", &source)]).unwrap();
}
}
fn model(extra: &str) -> String {
format!(
"type Account {{ email: Text(64) unique\nname: Text(16)\namount: Decimal(9, 2) default 0\nstatus: Int default 1 }}\n{extra}"
)
}
#[test]
fn schema_checks_seed_unique_keys_and_composite_indexes() {
let source = model(
"index(status, created_at)\nunique(email, name)\nseed { { email = \"admin@sample.test\", name = \"Admin\" } }",
);
let program = packages(&[("demo.account.model", &source)]).unwrap();
assert_eq!(program.models[0].indexes.len(), 2);
assert_eq!(program.models[0].seeds.len(), 1);
assert_eq!(program.models[0].seeds[0].key, [2]);
for extra in [
"index(missing)",
"index(name, name)",
"index(name)\nindex(name)",
"seed { { name = \"Admin\" } }",
"seed { { email = \"a\" } }",
"seed { { email = \"a\", name = \"a\", amount = 0.001 } }",
"seed { { email = \"a\", name = \"a\", id = 1 } }",
"seed {}\nseed {}",
"migrate same { drop legacy }\nmigrate same { drop old }",
"migrate bad { drop name }",
"migrate bad { drop created_at }",
"migrate empty {}",
] {
let source = model(extra);
let error = packages(&[("demo.account.model", &source)]).unwrap_err();
assert_eq!(error.code, "C014", "{extra}: {error:?}");
}
for source in [
"type Account { first: Text from old\nsecond: Text from old }",
"type Account { first: Text from second\nsecond: Text }",
"type Account { first: Text from created_at }",
] {
assert_eq!(
packages(&[("demo.account.model", source)])
.unwrap_err()
.code,
"C014"
);
}
}
#[test]
fn migration_sql_tracks_boundaries_and_rejects_unsafe_or_lossy_inputs() {
let statement = |sqlite: &str, postgres: &str, values: &str| {
let extra = format!(
"migrate normalize {{ before {{ sqlite = {}\npostgres = {}\nparameters = {values} }} }}",
serde_json::to_string(sqlite).unwrap(),
serde_json::to_string(postgres).unwrap()
);
packages(&[("demo.account.model", &model(&extra))])
};
statement("/* prefix */ UPDATE demo_account_model SET name = ?1 WHERE name = ?2 AND ';?90' = ';?90'; -- end", "/* nested /* ok */ */ UPDATE demo_account_model SET name = $1 WHERE name = $2 AND $text$;$90$text$ = $text$;$90$text$; -- end", "[\"new\",\"old\"]").unwrap();
for sql in [
"COMMIT",
"SELECT ?1, ?2",
"DROP TABLE account",
"UPDATE account SET name = ?1; DELETE FROM account WHERE name = ?2",
"UPDATE account SET name = ?3 WHERE name = ?2",
"UPDATE account SET name = $1 WHERE name = $2",
"UPDATE _dever_history SET name = ?1 WHERE name = ?2",
"UPDATE sqlite_master SET name = ?1 WHERE name = ?2",
"DELETE FROM main.'_dever_history' WHERE name IN (?1,?2)",
"UPDATE account SET name = '未闭合",
"UPDATE [未闭合 SET name = ?1 WHERE name = ?2",
] {
let error = statement(
sql,
"UPDATE account SET name = $1 WHERE name = $2",
"[\"new\",\"old\"]",
)
.unwrap_err();
assert_eq!(error.code, "C014", "{sql}: {error:?}");
}
for sql in [
"UPDATE account SET name = $tag$未闭合",
"UPDATE account SET name = $$未闭合",
"UPDATE pg_catalog.pg_class SET name = $1",
] {
assert_eq!(
statement("UPDATE account SET name = ?1", sql, "[\"new\"]")
.unwrap_err()
.code,
"C014"
);
}
for value in ["1.25", "1e999", "9223372036854775808", "name", "1 + 2"] {
assert_eq!(
statement(
"UPDATE account SET name = ?1",
"UPDATE account SET name = $1",
&format!("[{value}]")
)
.unwrap_err()
.code,
"C014"
);
}
statement(
"UPDATE account SET name = ?1 WHERE status = ?2",
"UPDATE account SET name = $1 WHERE status = $2",
"[\"new\",1e0]",
)
.unwrap();
}
+110
View File
@@ -0,0 +1,110 @@
use super::checked;
const TYPES: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\ntype Box { result: Result\n label: Text }\nfailValue(message: Text) (value: Int) { fail(Result.Failed(message)) }\nfailure(message: Text) (value: Result) { value = result(failValue(message)) }\n";
#[test]
fn rejects_discard_overwrite_and_nested_failure_loss() {
for body in [
"main() () { value = failure(\"bad\") }",
"main() (answer: Result) { answer = failure(\"bad\")\n answer = Result.Ready(1) }",
"main() (answer: Text) { box = Box { result = failure(\"bad\")\n label = \"x\" }\n answer = box.label }",
"main() (answer: Int) { values = [failure(\"bad\")]\n answer = length(values) }",
"ignore(value: Result) () {}",
"read(value: Result.Ready(number)) (answer: Int) { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { answer = length(message) }",
"main() (answer: Result?) { old = failure(\"bad\")\n answer = null }",
"main(old: Result, next: Result?) (answer: Result?) { answer = next }",
"main(old: Result, next: List<Result>) (answer: List<Result>) { answer = next }",
] {
let error = checked(&(TYPES.to_owned() + body)).unwrap_err();
assert_eq!(error.code, "C012", "{body}: {error:?}");
}
}
#[test]
fn capture_arguments_keep_default_propagation() {
let source = TYPES.to_owned()
+ "identity(value: Int) (answer: Int) { answer = value }\nmain() (answer: Result) { answer = result(identity(failValue(\"bad\"))) }";
let program = checked(&source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert_eq!(main.signature.failures.len(), 1);
assert!(main.signature.returns.is_none());
}
#[test]
fn accepts_return_propagation_translation_and_explicit_recovery() {
for body in [
"main() (answer: Result) { answer = failure(\"bad\") }",
"main() (answer: Int) { answer = failValue(\"bad\") }",
"main() (answer: Result) { old = failure(\"bad\")\n answer = Result.Failed(\"replacement\") }",
"identity(value: Result) (answer: Result) { answer = value }\nmain() (answer: Result) { first = failure(\"bad\")\n second = identity(first)\n answer = first }",
"read(value: Result.Ready(number)) (answer: Int) { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { fail(Result.Failed(message)) }",
"read(value: Result.Ready(number)) (answer: Int) recover(\"use zero when unavailable\") pure { answer = number }\nread(value: Result.Failed(message)) (answer: Int) pure recover(\"use zero when unavailable\") { answer = 0 }",
] {
let result = checked(&(TYPES.to_owned() + body));
assert!(result.is_ok(), "{body}: {result:?}");
}
}
#[test]
fn recovery_and_fail_are_not_generic_error_bypasses() {
for body in [
"main() (answer: Int) { fail(Result.Ready(1)) }",
"main() (answer: Int) { fail(1) }",
"main() (answer: Int) recover(\" \") { answer = 0 }",
"read(value: Result.Ready(number)) (answer: Int) recover(\"fallback\") { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { answer = 0 }",
"main() (answer: Int) { fail(Result.Failed(\"bad\"))\n answer = 1 }",
] {
assert!(checked(&(TYPES.to_owned() + body)).is_err(), "{body}");
}
let recovery = "handled(value: Result) (answer: Bool) recover(\"discard unavailable optional probe\") { answer = true }\n";
for expression in [
"false and handled(value)",
"true or handled(value)",
"flag and handled(value)",
] {
let body = format!(
"main(flag: Bool) (answer: Bool) {{ value = failure(\"bad\")\n answer = {expression} }}"
);
assert_eq!(
checked(&(TYPES.to_owned() + recovery + &body))
.unwrap_err()
.code,
"C012",
"{body}"
);
}
}
#[test]
fn capture_requires_exact_nominal_error_identity() {
let prefix = "type Problem { error Denied(code: Int)\n error Missing(message: Text) }\ntype Result { Ready(value: Int)\n error Failed(problem: Problem) }\nraise() (value: Int) { fail(Problem.Denied(7)) }\nmain() (answer: Result) { answer = result(raise()) }";
assert!(checked(prefix).is_ok());
let ambiguous = prefix.replace(
"error Failed(problem: Problem)",
"error Failed(problem: Problem)\n error Again(problem: Problem)",
);
assert!(checked(&ambiguous).is_err());
let wrong = prefix.replace("Failed(problem: Problem)", "Failed(problem: Int)");
assert!(checked(&wrong).is_err());
let normal = prefix.replace("error Missing", "Missing");
assert!(checked(&normal).is_ok());
let unrelated = format!(
"type Other {{ error Denied(code: Int) }}\n{}",
prefix.replace("Failed(problem: Problem)", "Failed(problem: Other)")
);
assert!(checked(&unrelated).is_err());
}
#[test]
fn capture_preserves_zero_and_multiple_success_outputs() {
for source in [
"type Done { Complete }\nwork() () {}\nmain() (value: Done) { value = result(work()) }",
"type Pair { Complete(number: Int, text: Text) }\nwork() (number: Int, text: Text) { number = 1\n text = \"ok\" }\nmain() (value: Pair) { value = result(work()) }",
] {
checked(source).unwrap();
}
}
+149
View File
@@ -0,0 +1,149 @@
use dever::compiler::format;
use dever::syntax::source::Source;
use std::path::PathBuf;
#[test]
fn format_keeps_literals_comments_groups_and_field_order() {
let text = "# leading\nmain( ) (answer: Int) {\n answer=(1+2)*3 # same line\n}\n";
let source = Source::new(0, "case.dever", text.into());
let output = format::format(&source).unwrap();
assert_eq!(
output,
"# leading\nmain() (answer: Int) {\n answer = (1 + 2) * 3 # same line\n}\n"
);
assert_eq!(
format::format(&Source::new(0, "case.dever", output.clone())).unwrap(),
output
);
let literals = Source::new(
0,
"case.dever",
"main() (answer: Text) { answer=\"# \\u{41} \\\"\" }\n".into(),
);
assert!(
format::format(&literals)
.unwrap()
.contains("\"# \\u{41} \\\"\"")
);
let fields = Source::new(
0,
"case.dever",
"main() (answer: Item) { answer=Item { second=2, first=1 } }\n".into(),
);
assert!(
format::format(&fields)
.unwrap()
.contains("second = 2, first = 1")
);
}
#[test]
fn format_preserves_crlf() {
let text = "main( ) (answer: Int) {\r\n answer=1\r\n}\r\n";
let output = format::format(&Source::new(0, "case.dever", text.into())).unwrap();
assert_eq!(
output,
text.replace("main( )", "main()")
.replace(" answer=1", " answer = 1")
);
}
#[test]
fn format_preserves_local_type_annotations() {
let source = Source::new(
0,
"case.dever",
"main() () { value:List<Int?>=[null,1] }".into(),
);
let formatted = format::format(&source).unwrap();
assert!(
formatted.contains("value: List<Int?> = [null, 1]"),
"{formatted}"
);
assert_eq!(
format::format(&Source::new(0, "case.dever", formatted.clone())).unwrap(),
formatted
);
}
#[test]
fn format_preserves_text_policy_and_camel_functions() {
let input = "type Category { name: Text(1,80)\ncode: Text lower unique\nbody: Text raw\nnote: Text? upper }\nreadName(input_value: Text) (output_value: Text) { output_value = input_value }";
let output = format::format(&Source::new(0, "model.dever", input.into())).unwrap();
for preserved in [
"code: Text lower unique",
"body: Text raw",
"note: Text? upper",
"readName(input_value: Text)",
] {
assert!(output.contains(preserved), "{output}");
}
assert_eq!(
format::format(&Source::new(0, "model.dever", output.clone())).unwrap(),
output
);
}
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"dever-format-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn project_prepares_module_and_tests_before_any_rewrite() {
let directory = Directory::new();
std::fs::create_dir(directory.0.join("module")).unwrap();
std::fs::create_dir(directory.0.join("test")).unwrap();
let module = directory.0.join("module/main.dever");
let test = directory.0.join("test/main.dever");
let input = "main( ) () {}\n";
std::fs::write(&module, input).unwrap();
std::fs::write(&test, "main( {").unwrap();
assert!(format::project(&directory.0, false).is_err());
assert_eq!(std::fs::read_to_string(&module).unwrap(), input);
std::fs::write(&test, input).unwrap();
let report = format::project(&directory.0, true).unwrap();
assert_eq!(report.checked, 2);
assert_eq!(report.changed.len(), 2);
assert_eq!(std::fs::read_to_string(&module).unwrap(), input);
format::project(&directory.0, false).unwrap();
assert!(
format::project(&directory.0, true)
.unwrap()
.changed
.is_empty()
);
}
#[test]
#[cfg(unix)]
fn project_rejects_links_without_touching_their_target() {
let directory = Directory::new();
let target = directory.0.join("actual.dever");
std::fs::write(&target, "main( ) () {}\n").unwrap();
std::os::unix::fs::symlink(&target, directory.0.join("link.dever")).unwrap();
assert!(
format::project(&directory.0, false)
.unwrap_err()
.contains("links")
);
assert_eq!(std::fs::read_to_string(target).unwrap(), "main( ) () {}\n");
}
+137
View File
@@ -0,0 +1,137 @@
use super::checked;
const BASE: &str = "increment(value: Int) (answer: Int) pure { answer = value + 1 }\ndecrement(value: Int) (answer: Int) pure { answer = value - 1 }\napply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nforward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }\n";
#[test]
fn specializes_handlers_and_reuses_identical_bindings() {
let source = BASE.to_owned()
+ "main() (answer: Int) pure { answer = forward(increment, 1) + forward(increment, 2) + forward(decrement, 3) }";
let program = checked(&source).unwrap();
assert_eq!(program.functions.len(), 7);
for name in ["apply", "forward"] {
let instances = program
.functions
.iter()
.filter(|function| function.signature.name == name)
.collect::<Vec<_>>();
assert_eq!(instances.len(), 2);
assert!(
instances
.iter()
.all(|function| function.signature.inputs.len() == 1
&& function.signature.static_parameters == 1)
);
}
let source =
BASE.to_owned() + "main() (answer: List<Int>) { answer = each(increment, [1, 2]) }";
let program = checked(&source).unwrap();
assert_eq!(program.functions.len(), 3);
}
#[test]
fn checks_unused_templates_and_handler_signatures() {
for body in [
"bad(route: handler(value: Int) (answer: Int)) (answer: Text) { answer = route(1) }",
"bad(route: handler(value: Int) (answer: Int)) (answer: Int) {}",
"bad(route: handler(value: List) ()) () {}",
"bad(route: handler() (answer: List<Int, Text>)) () {}",
"bad(route: handler(private value: Int) ()) () {}",
"bad(route: handler(value: Int) (value: Int)) () {}",
"bad(route: handler(value: Int) (answer: Int)) (route: Int) { route = 1 }",
"wrong(value: Int) (other: Int) { other = value }\nmain() (answer: Int) { answer = apply(wrong, 1) }",
"wrong(value: Text) (answer: Int) { answer = length(value) }\nmain() (answer: Int) { answer = apply(wrong, 1) }",
"main() (answer: Int) { answer = apply(increment(1), 1) }",
"main() (answer: Int) { increment = 1\n answer = apply(increment, 1) }",
"bad(route: handler(value: Text) (answer: Int), value: Int) (answer: Int) { answer = forward(route, value) }",
] {
assert!(checked(&(BASE.to_owned() + body)).is_err(), "{body}");
}
}
#[test]
fn handlers_are_not_values_or_clause_axes() {
for body in [
"bad(route: handler(value: Int) (answer: Int)) () { value = route }",
"bad(route: handler(value: Int) (answer: Int)) () { route = 1 }",
"bad(route: handler(value: Int) (answer: Int)) () { values = [route] }",
"bad(route: handler(value: Int) (answer: Int)) (answer: Bool) { answer = route == route }",
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nbad(route: other, value: other) (answer: Int) { answer = 0 }",
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nbad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = 0 }",
] {
assert!(checked(&(BASE.to_owned() + body)).is_err(), "{body}");
}
let source = BASE.to_owned()
+ "choose(route: handler(value: Int) (answer: Int), value: Int < 0) (answer: Int) { answer = 0 }\nchoose(route: handler(value: Int) (answer: Int), value: other) (answer: Int) { answer = route(value) }\nmain() (answer: Int) { answer = choose(increment, 1) }";
assert!(checked(&source).is_ok());
}
#[test]
fn handler_cycles_and_unrestricted_pure_effects_are_rejected() {
for source in [
BASE.to_owned() + "cycle(value: Int) (answer: Int) { answer = forward(cycle, value) }",
BASE.to_owned()
+ "unused(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = unused(route, value) }",
] {
assert_eq!(checked(&source).unwrap_err().code, "C007");
}
for body in [
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = route(value) }",
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = forward(route, value) }",
"bad(route: handler(value: Int) (answer: Int), values: List<Int>) (answers: List<Int>) pure { answers = each(route, values) }",
] {
assert_eq!(
checked(&(BASE.to_owned() + body)).unwrap_err().code,
"C013",
"{body}"
);
}
let source = BASE.to_owned()
+ "ignore(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = value }\npass(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = ignore(route, value) }";
assert!(checked(&source).is_ok());
}
#[test]
fn generic_error_outputs_keep_handling_obligations() {
let prefix = "type Result { Ready(value: Int)\n error Failed(message: Text) }\n";
for body in [
"bad(route: handler(value: Int) (answer: Result)) () { lost = route(1) }",
"bad(route: handler(value: Int) (answer: Result), values: List<Int>) (answer: Int) { lost = each(route, values)\n answer = length(lost) }",
"bad(route: handler(value: Int) (answer: Result)) (answer: Result) { answer = route(1)\n answer = Result.Ready(0) }",
] {
assert_eq!(
checked(&(prefix.to_owned() + body)).unwrap_err().code,
"C012",
"{body}"
);
}
let source = prefix.to_owned()
+ "valid(route: handler(value: Int) (answer: Result)) (answer: Result) { answer = route(1) }";
assert!(checked(&source).is_ok());
}
#[test]
fn each_binding_keeps_its_own_nominal_failure_set() {
let source = BASE.to_owned()
+ "type First { error Failed(code: Int) }\ntype Second { error Failed(code: Int) }\ntype FirstResult { Ready(value: Int)\n error Failed(problem: First) }\ntype SecondResult { Ready(value: Int)\n error Failed(problem: Second) }\nfirst(value: Int) (answer: Int) { fail(First.Failed(value)) }\nsecond(value: Int) (answer: Int) { fail(Second.Failed(value)) }\nfirstEntry() (answer: FirstResult) { answer = result(forward(first, 1)) }\nsecondEntry() (answer: SecondResult) { answer = result(forward(second, 2)) }";
let program = checked(&source).unwrap();
let instances = program
.functions
.iter()
.filter(|function| function.signature.name == "apply")
.collect::<Vec<_>>();
assert_eq!(instances.len(), 2);
assert!(
instances
.iter()
.all(|function| function.signature.failures.len() == 1)
);
assert_ne!(
instances[0].signature.failures,
instances[1].signature.failures
);
let wrong = source.replace(
"firstEntry() (answer: FirstResult)",
"firstEntry() (answer: SecondResult)",
);
assert_eq!(checked(&wrong).unwrap_err().code, "C005");
}
+114
View File
@@ -0,0 +1,114 @@
use dever::{
compiler::{build, model::Program, project::Project},
syntax::source::Source,
};
use std::path::Path;
#[path = "http/live.rs"]
mod live;
#[path = "http/streaming.rs"]
mod streaming;
#[path = "http/websocket.rs"]
mod websocket;
fn linked(text: &str) -> Result<Program, String> {
let mut project = Project::load(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/http.dever"),
)
.unwrap();
let source = project
.sources
.iter_mut()
.find(|source| source.path.ends_with("http.dever"))
.unwrap();
*source = Source::new(source.id, "case.dever", text.into());
project.check()
}
const ROUTE: &str = "route(request: dever.http.Request) (response: dever.http.Response) { response = dever.http.Response { status = 200, headers = [], body = request.body } }";
#[test]
fn leaf_http_handlers_do_not_convert_unused_request_fields() {
let project = Project::load(
&Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../test/performance/fixtures/http/concurrent.dever"),
)
.unwrap();
let program = project.check().unwrap();
let main = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
let source = dever::compiler::emit::generate(&program, &project.sources, &[main].into());
assert!(source.contains("http::serve_context"), "{source}");
assert!(source.contains("struct RequestView"), "{source}");
assert!(!source.contains("parts.method.to_string()"), "{source}");
assert!(
!source.contains("received_headers(&parts.headers)"),
"{source}"
);
}
#[test]
fn tls_pool_types_are_resources_and_configuration_failure_is_explicit() {
for (source, code) in [
(
"main() () { lost = result(dever.tls.client(dever.bytes.fromText(\"invalid\"))) }",
"C012",
),
(
"main(config: dever.system.ClientTls) (same: Bool) { same = config == config }",
"C005",
),
(
"main() (config: dever.system.ClientTls) { config = dever.system.tlsSystem() }",
"C006",
),
(
"main() (client: dever.system.HttpClient) pure { client = dever.http.client(\"http://localhost\", dever.tls.system(), dever.http.defaultLimits(), dever.http.defaultPoolLimits()) }",
"C013",
),
] {
let error = linked(source).unwrap_err();
assert!(error.contains(code), "{error}");
}
let program = linked("main() (client: dever.system.HttpClient) { client = dever.http.client(\"http://localhost\", dever.tls.system(), dever.http.defaultLimits(), dever.http.defaultPoolLimits()) }").unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(main.signature.effects.schedule && main.signature.effects.suspend);
}
#[test]
fn http_effects_and_runtime_selection_follow_reachable_calls() {
for (source, http, schedule) in [
("main() (answer: Int) { answer = 1 }".into(), false, false),
("main(request: dever.http.Request) (response: dever.http.Response) { response = dever.http.send(\"127.0.0.1\", 80, request, dever.http.defaultLimits()) }".into(), true, false),
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () {{ dever.http.serve(route, listener, dever.http.defaultLimits()) }}"), true, true),
] {
let program = linked(&source).unwrap();
let main = program.functions.iter().position(|function| function.signature.name == "main").unwrap();
let effects = program.functions[main].signature.effects;
assert_eq!(effects.network, http);
assert_eq!(effects.suspend, http);
assert_eq!(effects.schedule, schedule);
assert_eq!(build::runtime_features(&program, &program.reachable([main])).contains(&"http"), http);
}
}
#[test]
fn http_preserves_handler_types_effect_boundaries_and_failure_obligations() {
for (source, code) in [
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () {{ dever.system.httpServe(route, listener, dever.http.defaultLimits()) }}"), "C006"),
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () pure {{ dever.http.serve(route, listener, dever.http.defaultLimits()) }}"), "C013"),
("route(request: Int) (response: Int) { response = request }\nmain(listener: dever.system.Listener) () { dever.http.serve(route, listener, dever.http.defaultLimits()) }".into(), "C005"),
("route(request: dever.http.Request) (response: dever.http.Response) { dever.io.println(\"blocking\")\nresponse = dever.http.Response { status = 200, headers = [], body = request.body } }\nmain(listener: dever.system.Listener) () { dever.http.serve(route, listener, dever.http.defaultLimits()) }".into(), "C013"),
("main(request: dever.http.Request) () { lost = result(dever.http.send(\"127.0.0.1\", 80, request, dever.http.defaultLimits())) }".into(), "C012"),
] {
let error = linked(&source).unwrap_err();
assert!(error.contains(code), "{source}\n{error}");
}
}
+37
View File
@@ -0,0 +1,37 @@
use super::*;
const LIVE: &str = "route(request: dever.http.Request, reply: dever.system.HttpReply) () { dever.sse.start(reply, [])\ndever.sse.send(reply, dever.sse.Event { event = \"tick\", data = request.target, id = null, retry_ms = 10 }) }\nmain(listener: dever.system.Listener) () { dever.http.serveLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits()) }";
#[test]
fn live_static_handler_shapes_effects_and_metadata_are_checked() {
let program = linked(LIVE).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(
main.signature.effects.network
&& main.signature.effects.schedule
&& main.signature.effects.suspend
);
for (source, code) in [
(LIVE.replace("reply: dever.system.HttpReply", "reply: dever.system.Socket"), "C005"),
(LIVE.replace("dever.sse.start(reply, [])", "dever.io.println(\"blocked\")\ndever.sse.start(reply, [])"), "C013"),
("main(reply: dever.system.HttpReply) (same: Bool) { same = reply == reply }".into(), "C005"),
("main(reply: dever.system.HttpReply) () { lost = result(dever.http.write(reply, dever.bytes.fromText(\"x\"))) }".into(), "C012"),
("main(reply: dever.system.HttpReply) () { dever.system.httpFinish(reply) }".into(), "C006"),
] { let error = linked(&source).unwrap_err(); assert!(error.contains(code), "{source}\n{error}"); }
}
#[test]
fn live_context_keeps_concrete_type_and_rejects_nontransferable_values() {
let source = "type Context { prefix: Text }\nroute(request: dever.http.Request, reply: dever.system.HttpReply, context: Context) () { dever.http.start(reply, 200, [])\ndever.http.write(reply, dever.bytes.fromText(context.prefix + request.target)) }\nmain(listener: dever.system.Listener, context: Context) () { dever.system.httpServeLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits(), context) }";
linked(source).unwrap();
let error = linked(&source.replace(
"context: Context) () { dever.http.start",
"context: Text) () { dever.http.start",
))
.unwrap_err();
assert!(error.contains("C005"), "{error}");
}
+44
View File
@@ -0,0 +1,44 @@
use super::*;
#[test]
fn streaming_original_source_contract_checks_and_selects_http_runtime() {
let project = Project::load(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/http-stream.dever"),
)
.unwrap();
let program = project.check().unwrap();
let entry = program
.functions
.iter()
.position(|function| function.signature.name == "verify")
.unwrap();
let effects = program.functions[entry].signature.effects;
assert!(effects.network && effects.suspend && effects.schedule);
assert!(build::runtime_features(&program, &program.reachable([entry])).contains(&"http"));
}
#[test]
fn streaming_intrinsics_preserve_types_effects_and_failure_obligations() {
linked("main(client: dever.system.HttpClient, request: dever.http.Request) () { response = dever.http.open(client, request)\nclose(response.body) }").unwrap();
for (source, code) in [
(
"main(client: dever.system.HttpClient, request: dever.http.Request) (response: dever.http.StreamResponse) pure { response = dever.http.open(client, request) }",
"C013",
),
(
"main(client: dever.system.HttpClient, request: dever.http.Request) (response: dever.http.StreamResponse) { response = dever.http.openStream(client, request) }",
"C005",
),
(
"main(client: dever.system.HttpClient, request: dever.http.Request) () { lost = result(dever.http.open(client, request)) }",
"C012",
),
(
"main(client: dever.system.HttpClient, request: dever.http.Request) () { dever.system.httpOpen(client, request) }",
"C006",
),
] {
let error = linked(source).unwrap_err();
assert!(error.contains(code), "{source}\n{error}");
}
}
+56
View File
@@ -0,0 +1,56 @@
use super::*;
#[test]
fn websocket_original_contract_effects_and_resource_rules() {
let source = "route(request: dever.http.Request, reply: dever.system.HttpReply) () { socket = dever.websocket.accept(reply, dever.websocket.defaultLimits())\ndever.websocket.send(socket, dever.websocket.Message.Text(request.target))\ndever.websocket.close(socket, 1000, \"\") }\nmain(listener: dever.system.Listener) () { dever.http.serveLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits()) }";
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(
main.signature.effects.network
&& main.signature.effects.suspend
&& main.signature.effects.schedule
);
for (source, code) in [
(
"main(socket: dever.system.WebSocket) (same: Bool) { same = socket == socket }",
"C005",
),
(
"main(socket: dever.system.WebSocket) () { dever.system.wsReceive(socket) }",
"C006",
),
(
"main(socket: dever.system.WebSocket) () { lost = result(dever.websocket.receive(socket)) }",
"C012",
),
(
"main() () { lost = result(dever.websocket.connect(\"127.0.0.1\", 80, \"/\", dever.websocket.defaultLimits())) }",
"C012",
),
(
"main(socket: dever.system.WebSocket) (value: dever.websocket.Message) { value = first(dever.websocket.messages(socket)) }",
"C005",
),
] {
let error = linked(source).unwrap_err();
assert!(error.contains(code), "{source}\n{error}");
}
}
#[test]
fn websocket_messages_are_lazy_and_keep_terminal_failure_obligations() {
let source = "main(socket: dever.system.WebSocket) (stream: AsyncStream<dever.websocket.MessageEvent>) { stream = dever.websocket.messages(socket) }";
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(!main.signature.effects.suspend);
let source = "read(event: dever.websocket.MessageEvent.Read(message)) () {}\nread(event: dever.websocket.MessageEvent.Failed(message)) () { fail(dever.websocket.MessageEvent.Failed(message)) }\nmain(socket: dever.system.WebSocket) () { each(read, dever.websocket.messages(socket)) }";
linked(source).unwrap();
}
+63
View File
@@ -0,0 +1,63 @@
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let sources = [
Source::new(0, "main.dever", text.into()),
Source::new(
1,
"lib/dever/id.dever",
include_str!("../../../lib/dever/id.dever").into(),
),
];
let modules = sources
.iter()
.map(dever::syntax::parse)
.collect::<Result<Vec<_>, _>>()?;
check::check(modules, &["main".into(), "dever.id".into()])
}
#[test]
fn ids_are_opaque_and_distinct_from_text() {
for source in [
"main(text: Text) (answer: Id) { answer = text }",
"main(id: Id) (answer: Text) { answer = id }",
"main(id: Id) (answer: Int) { answer = length(id) }",
"main(id: Id) (answer: Bool) { answer = id == \"x\" }",
"main(id: Id) (answer: Bool) { answer = id < id }",
"main(id: Id) (answer: Id) { answer = id + id }",
"main(id: Id) (answer: Text) { answer = id.value }",
"main() (answer: Id) { answer = Id { value = \"x\" } }",
"main(id: Id) () { value = dever.id.fromText(id) }",
"main() () { value = dever.id.toText(\"x\") }",
] {
assert!(linked(source).is_err(), "{source}");
}
assert_eq!(
linked("main() (answer: Id) { answer = dever.system.idFromText(\"x\") }")
.unwrap_err()
.code,
"C006"
);
}
#[test]
fn ids_can_be_keys_and_nullable_values_without_implicit_conversion() {
for source in [
"main(id: Id) (answer: Map<Id, Int>) { answer = { id = 1 } }",
"main(id: Id, values: Map<Id, Text>) (answer: Text?) { answer = get(values, id) }",
"main(id: Id, values: Map<Id, Id?>) (answer: Id?) { answer = get(values, id) }",
"main(values: List<Text>) (answer: List<Id>) { answer = each(dever.id.fromText, values) }",
"main(id: Id?) (answer: Id?) { answer = id }",
] {
let result = linked(source);
assert!(result.is_ok(), "{result:?}");
}
assert_eq!(
linked("main(id: Id) () { alias = id\n value = { id = 1\n alias = 2 } }")
.unwrap_err()
.code,
"C002"
);
assert!(linked("main(values: Map<Id, Int>) () { found = get(values, \"x\") }").is_err());
}
+138
View File
@@ -0,0 +1,138 @@
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
fn linked(text: &str, identity: &str) -> Result<Program, Diagnostic> {
let sources = [
Source::new(0, "main.dever", text.into()),
Source::new(
1,
"lib/dever/io.dever",
include_str!("../../../../lib/dever/io.dever").into(),
),
];
let modules = sources
.iter()
.map(dever::syntax::parse)
.collect::<Result<Vec<_>, _>>()?;
check::check(modules, &[identity.into(), "dever.io".into()])
}
#[test]
fn pure_rejects_direct_transitive_captured_and_handler_io() {
let prefix = "write(value: Text) (answer: Text) { dever.io.println(value)\n answer = value }\napply(route: handler(value: Text) (answer: Text), value: Text) (answer: Text) { answer = route(value) }\n";
for source in [
"main(path: Text) () pure { file = dever.io.open(path) }".into(),
"main(path: Text) (answer: dever.io.OpenResult) pure { answer = result(dever.io.open(path)) }".into(),
"main() () pure { dever.io.println(\"hello\") }".into(),
prefix.to_owned() + "main() (answer: Text) pure { answer = write(\"hello\") }",
prefix.to_owned() + "main() (answer: Text) pure { answer = apply(write, \"hello\") }",
prefix.to_owned() + "main() (answer: List<Text>) pure { answer = each(write, [\"hello\"]) }",
"unused(route: handler() ()) () pure { route() }".into(),
] {
let error = linked(&source, "main").unwrap_err();
assert_eq!(error.code, "C013", "{source}: {error:?}");
}
let source = "identity(value: Text) (answer: Text) { answer = value }\napply(route: handler(value: Text) (answer: Text), value: Text) (answer: Text) { answer = route(value) }\nmain() (answer: Text) pure { answer = apply(identity, \"hello\") }";
assert!(linked(source, "main").is_ok());
}
#[test]
fn resource_equality_cannot_hide_inside_value_containers() {
for source in [
"main(file: dever.system.File) (answer: Bool) { answer = file == file }",
"main(file: dever.system.File) (answer: Bool) { answer = [file] == [file] }",
"type Box { file: dever.system.File }\nmain(value: Box) (answer: Bool) { answer = value == value }",
"type Box { Some(file: dever.system.File)\n None }\nmain(value: Box) (answer: Bool) { answer = value == value }",
"main(file: dever.system.File?) (answer: Bool) { answer = file == null }",
"main(files: Map<dever.system.File, Int>) () {}",
] {
assert_eq!(linked(source, "main").unwrap_err().code, "C005", "{source}");
}
assert!(linked("type Box { files: List<dever.system.File> }\nmain(value: Box) (answer: Box) { answer = value }", "main").is_ok());
}
#[test]
fn file_errors_keep_nominal_identity_and_handling_obligations() {
let source =
"main(path: Text) (answer: dever.io.OpenResult) { answer = result(dever.io.open(path)) }";
assert!(linked(source, "main").is_ok());
assert_eq!(
linked(
"main(path: Text) () { lost = result(dever.io.open(path)) }",
"main"
)
.unwrap_err()
.code,
"C012"
);
assert_eq!(linked("type OpenResult { Opened(file: dever.system.File)\n error Failed(message: Text) }\nmain(path: Text) (answer: OpenResult) { answer = result(dever.io.open(path)) }", "main").unwrap_err().code, "C005");
}
#[test]
fn raw_file_intrinsics_and_unmediated_app_effects_are_rejected() {
assert_eq!(
linked(
"main() () { value = dever.system.fileOpen(\"file\") }",
"main"
)
.unwrap_err()
.code,
"C006"
);
for identity in ["app.docs.app.files", "app.docs.domain.files"] {
let source = "helper(path: Text) (file: dever.system.File) { file = dever.io.open(path) }\nmain(path: Text) (file: dever.system.File) { file = helper(path) }";
assert_eq!(linked(source, identity).unwrap_err().code, "C013");
}
}
#[test]
fn stream_operations_remain_lazy_and_effectful() {
let prefix = "count(item: Int, state: Int) (next: Int) { next = state + item }\n";
for source in [
"main(stream: Stream<Int>) () pure { close(stream) }".into(),
prefix.to_owned()
+ "main(stream: Stream<Int>) (answer: Int) pure { answer = reduce(count, stream, 0) }",
] {
assert_eq!(
linked(&source, "main").unwrap_err().code,
"C013",
"{source}"
);
}
for source in [
"same(item: Int) (answer: Int) { answer = item }\nmain(stream: Stream<Int>) (answer: List<Int>) { answer = each(same, stream) }",
"yes(item: Int) (answer: Bool) { answer = true }\nmain(stream: Stream<Int>) (answer: Int?) { answer = find(yes, stream) }",
"main(stream: Stream<Int>) (answer: Int) { answer = length(stream) }",
"main(stream: Stream<Int>) (answer: Bool) { answer = stream == stream }",
"main(file: dever.system.File) () { close(file) }",
] {
assert_eq!(linked(source, "main").unwrap_err().code, "C005", "{source}");
}
assert!(linked("main(file: dever.system.File) () { stream = dever.io.chunks(file, 1)\n close(stream) }", "main").is_ok());
}
#[test]
fn stream_events_cannot_drop_failures_or_leak_private_types() {
for source in [
"ignore(event: dever.io.ReadEvent) () {}\nmain(stream: Stream<dever.io.ReadEvent>) () { each(ignore, stream) }",
"ignore(event: dever.io.ReadEvent, state: Int) (next: Int) { next = state }\nmain(stream: Stream<dever.io.ReadEvent>) (answer: Int) { answer = reduce(ignore, stream, 0) }",
"main(file: dever.system.File) () { lost = result(dever.io.chunks(file, 0)) }",
] {
assert_eq!(linked(source, "main").unwrap_err().code, "C012", "{source}");
}
assert_eq!(
linked(
"type Hidden { value: Int }\npublic type Visible { stream: Stream<Hidden> }",
"main"
)
.unwrap_err()
.code,
"C006"
);
assert_eq!(
linked("type Cycle { stream: Stream<Cycle> }", "main")
.unwrap_err()
.code,
"C007"
);
}
+67
View File
@@ -0,0 +1,67 @@
use super::{database::ERROR, package::packages};
mod tasks;
fn checked(
app: &str,
job: &str,
api: &str,
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
packages(&[
("dever.database", ERROR),
("demo.queue.app", app),
("demo.queue.job", job),
("demo.queue.api", api),
])
}
const APP: &str = "type Payload { name: Text }\nsubmit(key: Text) (id: Id) { id = dever.job.enqueue(job.publish, Payload { name = key }, key) }\napply(input: Payload) () {}";
const JOB: &str = "database default\njob publish(input: app.Payload) () retry(2) timeout(1000) { app.apply(input) }";
#[test]
fn persistent_job_is_a_separate_entry_and_not_a_synchronous_call() {
let program = checked(APP, JOB, "cmd submit = app.submit").unwrap();
assert_eq!(program.jobs.len(), 1);
let job = &program.jobs[0];
assert_eq!(job.name, "demo.queue.job.publish");
assert!(program.entries().any(|entry| entry == job.function));
assert!(
program.functions[program.commands[0].function]
.signature
.effects
.enqueue
);
let called = APP.replace(
"dever.job.enqueue(job.publish, Payload { name = key }, key)",
"job.publish(Payload { name = key })",
);
assert!(checked(&called, JOB, "cmd submit = app.submit").is_err());
assert!(checked(APP, JOB, "public post submit = app.submit").is_err());
}
#[test]
fn job_contract_rejects_bad_policy_payload_effects_and_schedules() {
for job in [
JOB.replace("retry(2)", "retry(0)"),
JOB.replace("timeout(1000)", "timeout(0)"),
JOB.replace("database default\n", ""),
JOB.replace("app.apply(input)", "dever.sleep(1)"),
format!("{JOB}\nschedule publish = \"* * * * *\""),
] {
assert!(
checked(APP, &job, "cmd submit = app.submit").is_err(),
"{job}"
);
}
let secret = APP.replace("name: Text", "name: Secret");
assert!(checked(&secret, JOB, "cmd submit = app.submit").is_err());
let app = "run() () {}";
let jobs = "database default\njob cleanup() () retry(1) timeout(100) { app.run() }\nschedule cleanup = \"*/5 * * * *\"";
let program = checked(app, jobs, "").unwrap();
assert_eq!(program.schedules.len(), 1);
let features =
dever::compiler::build::runtime_features(&program, &program.reachable(program.entries()));
assert!(features.contains(&"database"));
assert_eq!(features.contains(&"postgres"), cfg!(feature = "postgres"));
assert!(checked(app, &jobs.replace("*/5", "*/0"), "").is_err());
}
+118
View File
@@ -0,0 +1,118 @@
use super::{APP, JOB, checked};
fn app(body: &str) -> String {
format!(
"{}\ntransaction send(key: Text) () {{ id = submit(key) }}\nlaunch() () {{ {body} }}",
APP
)
}
#[test]
fn tasks_can_inherit_enqueue_identity_without_sharing_a_transaction() {
for body in [
"pending = run(send(\"one\"))\nwait(pending)\nsend(\"after\")",
"pool = group(2)\nrun(pool, send(\"one\"))\nrun(pool, send(\"two\"))\nwait(pool)\nsend(\"after\")",
"parallelEach(send, [\"one\", \"two\"], 2)\nsend(\"after\")",
] {
let source = app(body);
let program = checked(&source, JOB, "long cmd launch = app.launch").unwrap();
let signature = &program.functions[program.commands[0].function].signature;
assert!(signature.effects.enqueue);
assert!(!signature.effects.request);
assert!(!signature.effects.unscoped_write);
assert!(signature.effects.concurrent || signature.effects.parallel);
// 普通入口不能把自动事务变成多个独立提交;必须显式声明 long。
let error = checked(&source, JOB, "cmd launch = app.launch").unwrap_err();
assert_eq!(error.code, "C014", "{body}: {error:?}");
}
}
#[test]
fn long_jobs_can_launch_children_only_through_explicit_transactions() {
let source = app("parallelEach(send, [\"one\", \"two\"], 2)");
let jobs = format!("{JOB}\nlong job fanout() () retry(1) timeout(1000) {{ app.launch() }}");
assert!(checked(&source, &jobs, "").is_ok());
assert_eq!(
checked(&source, &jobs.replace("long job", "job"), "")
.unwrap_err()
.code,
"C014"
);
}
#[test]
fn enqueue_does_not_escape_transaction_or_synchronous_worker_restrictions() {
for body in [
"pending = run(send(\"one\"))\nwait(pending)",
"pool = group(1)\nrun(pool, send(\"one\"))\nwait(pool)",
"parallelEach(send, [\"one\"], 1)",
] {
let source = app(body).replace("launch() ()", "transaction launch() ()");
assert_eq!(
checked(&source, JOB, "long cmd launch = app.launch")
.unwrap_err()
.code,
"C014",
"{body}"
);
}
for boundary in ["blocking", "parallel"] {
let source = app(&format!("{boundary}(send(\"one\"))"));
assert_eq!(
checked(&source, JOB, "long cmd launch = app.launch")
.unwrap_err()
.code,
"C013"
);
}
let unscoped =
app("pending = run(send(\"one\"))\nwait(pending)").replace("transaction send", "send");
assert_eq!(
checked(&unscoped, JOB, "long cmd launch = app.launch")
.unwrap_err()
.code,
"C014"
);
}
#[test]
fn public_enqueue_remains_forbidden_through_tasks() {
let source = app("pending = run(send(\"one\"))\nwait(pending)");
assert_eq!(
checked(&source, JOB, "public long post launch = app.launch")
.unwrap_err()
.code,
"C014"
);
}
#[test]
fn enqueue_capability_does_not_make_request_identity_transferable() {
for body in [
"pending = run(send(\"one\"))\nwait(pending)",
"pool = group(1)\nrun(pool, send(\"one\"))\nwait(pool)",
"parallelEach(send, [\"one\"], 1)",
] {
let source = app(body).replace(
"id = submit(key)",
"identity = dever.auth.id()\nid = submit(identity + key)",
);
let error = super::packages(&[
("dever.database", super::ERROR),
(
"dever.api",
"public type Error { error Invalid\nerror Unauthorized\nerror Forbidden }",
),
(
"dever.auth",
"public id() (value: Text) { value = dever.system.authId() }",
),
("demo.queue.app", &source),
("demo.queue.job", JOB),
("demo.queue.api", "long cmd launch = app.launch"),
])
.unwrap_err();
assert_eq!(error.code, "C013", "{body}: {error:?}");
}
}
+184
View File
@@ -0,0 +1,184 @@
use dever::{
compiler::{check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
("dever.json", include_str!("../../../lib/dever/json.dever")),
(
"dever.json.value",
include_str!("../../../lib/dever/json/value.dever"),
),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (_, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("library{index}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn encode_rejects_non_wire_types_and_untyped_empty_values() {
for source in [
"main(value: Secret) (answer: Json) { answer = dever.json.encode(value) }",
"type Row { value: Secret }\nmain(value: Secret) (answer: Json) { row = Row { value = value } answer = dever.json.encode(row) }",
"type Row { private values: List<Int> }\nmain(values: List<Int>) (answer: Json) { row = Row { values = values } answer = dever.json.encode(row) }",
"type Row { private value: Int }\nmain(value: Row) (answer: Json) { answer = dever.json.encode(value) }",
"type Row { value: Int > 0 }\nmain(values: List<Row?>) (answer: Json) { answer = dever.json.encode(values) }",
"type State { Ready(value: Int) }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }",
"type State { Ready\n error Failed }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }",
"main(value: Map<Text, Int>) (answer: Json) { answer = dever.json.encode(value) }",
"main(value: dever.system.File) (answer: Json) { answer = dever.json.encode(value) }",
"main() (answer: Json) { answer = dever.json.encode([]) }",
"main() (answer: Json) { answer = dever.json.encode(null) }",
] {
assert!(linked(source).is_err(), "{source}");
}
assert!(linked("type Row { amount: Decimal }\nmain(values: List<Row?>) (answer: Json) pure { answer = dever.json.encode(values) }").is_ok());
}
#[test]
fn payload_free_choices_use_the_existing_wire_codec() {
assert!(linked("type State { Ready\n Busy }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }").is_ok());
assert!(linked("type State { Ready\n Busy }\ntype Row { state: State\n optional: State?\n states: List<State> }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }").is_ok());
}
#[test]
fn typed_json_decode_can_be_captured_without_an_intermediate_function() {
let prefix = "type Row { name: Text }\ntype Result { Decoded(value: Row)\n error Invalid(cause: dever.json.JsonResult) }\n";
let program = linked(&format!("{prefix}main(payload: Json) (output: Result) {{ output = result(dever.json.decode(payload)) }}")).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(main.signature.failures.is_empty());
assert!(
linked(&format!(
"{prefix}main(payload: Json) () {{ lost: Result = result(dever.json.decode(payload)) }}"
))
.unwrap_err()
.code
== "C012"
);
let source = format!(
"{prefix}type Failure {{ error Missing }}\nraise() (payload: Json) {{ fail(Failure.Missing) }}\nmain() (output: Result) {{ output = result(dever.json.decode(raise())) }}"
);
let program = linked(&source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert_eq!(main.signature.failures.len(), 1);
assert_eq!(
program.definitions[main.signature.failures[0].definition].name,
"Failure"
);
for source in [
format!("{prefix}main(payload: Json) () {{ lost = result(dever.json.decode(payload)) }}"),
"type Wrong { Done(value: Int)\n error Invalid(message: Text) }\nmain(payload: Json) (output: Wrong) { output = result(dever.json.decode(payload)) }".into(),
"type Limited { Done(value: Int > 0)\n error Invalid(cause: dever.json.JsonResult) }\nmain(payload: Json) (output: Limited) { output = result(dever.json.decode(payload)) }".into(),
] {
assert!(linked(&source).is_err(), "{source}");
}
}
#[test]
fn json_failures_preserve_capture_and_argument_propagation() {
let source = "type Failure { error Missing }\nraise() (value: Int) { fail(Failure.Missing) }\nmain() (answer: dever.json.JsonResult) { answer = result(dever.json.encode(raise())) }";
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert_eq!(main.signature.failures.len(), 1);
assert_eq!(
program.definitions[main.signature.failures[0].definition].name,
"Failure"
);
for expression in [
"dever.json.encode(1)",
"dever.json.propertyValue(dever.json.fromText(\"42\"), \"a\")",
] {
let source = format!("main() () {{ lost = result({expression}) }}");
assert_eq!(linked(&source).unwrap_err().code, "C012");
}
}
#[test]
fn typed_decode_requires_an_explicit_wire_type_and_propagates_errors() {
let source = "type Row { names: List<Text>\noptional: Int? }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }";
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert_eq!(main.signature.failures.len(), 1);
for source in [
"main(value: Json) () { row = dever.json.decode(value) }",
"main(value: Text) (row: Int) { row = dever.json.decode(value) }",
"type Row { private secret: Text }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }",
] {
assert!(linked(source).is_err(), "{source}");
}
}
#[test]
fn local_type_annotations_supply_context_without_wrapper_functions() {
assert!(linked("type Row { names: List<Text> }\nmain(value: Json) (names: List<Text>) { row: Row = dever.json.decode(value)\n names = row.names }").is_ok());
assert!(linked("main() (answer: Int) { values: List<Int> = []\n values = [1]\n answer = length(values) }").is_ok());
for (source, code) in [
("main(value: Int) () { value: Int = 1 }", "C002"),
("main() () { value = 1\n value: Int = 2 }", "C002"),
("main() () { value: Int = true }", "C005"),
("main() () { value: Int = 1\n value = true }", "C005"),
(
"main() () { value: Channel<Int?> = channel(Int?, 1) }",
"C013",
),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn document_is_a_nominal_source_type_and_intrinsics_remain_private() {
assert!(
linked("main(document: dever.json.value.Document) (value: Int) { value = document.root }")
.is_ok()
);
assert!(linked("type Document { root: Int }\nmain(document: Document) (value: Text) { value = dever.json.stringify(document) }").is_err());
for expression in [
"dever.system.jsonFromText(\"{}\")",
"dever.system.jsonObject({})",
] {
assert_eq!(
linked(&format!("main() () {{ value = {expression} }}"))
.unwrap_err()
.code,
"C006"
);
}
}
+148
View File
@@ -0,0 +1,148 @@
use dever::{
compiler::{build, check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
("dever.io", include_str!("../../../lib/dever/io.dever")),
("dever.time", include_str!("../../../lib/dever/time.dever")),
("dever.task", include_str!("../../../lib/dever/task.dever")),
(
"dever.net",
include_str!("../../../lib/dever/net/socket.dever"),
),
(
"dever.net",
include_str!("../../../lib/dever/net/listener.dever"),
),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (_, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("source{index}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn network_effects_and_runtime_features_match_the_actual_entry() {
for (source, suspends, schedules, net) in [
("main() (answer: Int) { answer = 42 }", false, false, false),
(
"main() (socket: dever.system.Socket?) { socket = null }",
false,
false,
true,
),
(
"main(socket: dever.system.Socket) () { dever.net.close(socket) }",
false,
false,
true,
),
(
"main(listener: dever.system.Listener) (port: Int) { port = dever.net.port(listener) }",
false,
false,
true,
),
(
"main() (socket: dever.system.Socket) { socket = dever.net.connectTimeout(\"127.0.0.1\", 80, 1000) }",
true,
false,
true,
),
(
"main() (socket: dever.system.Socket) { socket = dever.net.connect(\"localhost\", 80) }",
true,
true,
true,
),
(
"main() (listener: dever.system.Listener) { listener = dever.net.listen(\"127.0.0.1\", 0) }",
true,
true,
true,
),
] {
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
let effects = program.functions[main].signature.effects;
assert_eq!(effects.suspend, suspends, "{source}");
assert_eq!(effects.schedule, schedules, "{source}");
assert_eq!(
build::runtime_features(&program, &program.reachable([main])).contains(&"net"),
net,
"{source}"
);
}
}
#[test]
fn network_resources_obey_visibility_equality_and_effect_boundaries() {
for (source, code) in [
("main() () { value = dever.system.tcpPort(null) }", "C006"),
(
"main(socket: dever.system.Socket) (answer: Bool) { answer = socket == socket }",
"C005",
),
(
"main(values: List<dever.system.Listener?>) (answer: Bool) { answer = values == values }",
"C005",
),
(
"main(listener: dever.system.Listener) (port: Int) pure { port = dever.net.port(listener) }",
"C013",
),
("main(value: dever.net.SystemReadResult) () {}", "C006"),
(
"main(socket: dever.system.Socket) () { value = dever.net.read(socket, true) }",
"C005",
),
(
"main(socket: dever.system.Socket) () { value = dever.net.read(socket, 1)\n dever.io.println(\"blocking\") }",
"C013",
),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn lazy_network_streams_do_not_claim_unread_errors_but_captured_failures_remain_obligations() {
for source in [
"main(socket: dever.system.Socket) () { close(dever.net.chunks(socket, 1)) }",
"main(listener: dever.system.Listener) () { close(dever.net.connections(listener)) }",
] {
assert!(linked(source).is_ok(), "{source}");
}
for source in [
"main(socket: dever.system.Socket) () { lost = result(dever.net.read(socket, 1)) }",
"main(socket: dever.system.Socket) () { lost = result(dever.net.chunks(socket, 1)) }",
"main(socket: dever.system.Socket) () { lost = result(dever.net.close(socket)) }",
] {
assert_eq!(linked(source).unwrap_err().code, "C012", "{source}");
}
}
@@ -0,0 +1,61 @@
use super::super::checked;
#[test]
fn handler_ranges_check_unused_templates_inference_and_forwarding() {
let apply = "apply(route: handler(value: Int >= 1) (answer: Int >= 2)) (answer: Int >= 2) { answer = route(1) }\n";
checked(apply).unwrap();
checked(&(apply.to_owned() + "twice(value: Int) (answer: Int) { answer = 2 }\nmake() (answer: Int >= 2) { answer = apply(twice) }")).unwrap();
assert_eq!(checked(&(apply.to_owned() + "identity(value: Int) (answer: Int) { answer = value }\nmake() (answer: Int) { answer = apply(identity) }")).unwrap_err().code, "C011");
assert_eq!(
checked(
"bad(route: handler(value: Int >= 1) (answer: Int)) (answer: Int) { answer = route(0) }"
)
.unwrap_err()
.code,
"C011"
);
checked(&(apply.to_owned() + "forward(route: handler(value: Int) (answer: Int >= 3)) (answer: Int >= 2) { answer = apply(route) }")).unwrap();
for contract in [
"handler(value: Int >= 2) (answer: Int >= 3)",
"handler(value: Int) (answer: Int >= 1)",
] {
assert_eq!(
checked(&format!(
"{apply}forward(route: {contract}) (answer: Int) {{ answer = apply(route) }}"
))
.unwrap_err()
.code,
"C011"
);
}
}
#[test]
fn prototype_cache_keeps_distinct_range_contracts() {
let prefix = "first(route: handler(value: Int) (answer: Int >= 2)) (answer: Int >= 2) { answer = route(1) }\n";
assert_eq!(checked(&(prefix.to_owned() + "second(route: handler(value: Int) (answer: Int >= 1)) (answer: Int >= 2) { answer = route(1) }")).unwrap_err().code, "C011");
assert_eq!(checked("first(route: handler(value: Int >= 1) (answer: Int)) (answer: Int) { answer = route(1) }\nsecond(route: handler(value: Int >= 2) (answer: Int)) (answer: Int) { answer = route(1) }").unwrap_err().code, "C011");
}
#[test]
fn collection_handlers_check_items_context_and_repeated_state() {
checked("valid(route: handler(value: Int >= 1) (answer: Int >= 2)) (answer: List<Int>) { answer = each(route, [1, 2]) }").unwrap();
for body in [
"bad(route: handler(value: Int >= 1) (answer: Int), values: List<Int>) (answer: List<Int>) { answer = each(route, values) }",
"bad(route: handler(value: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [0, 1]) }",
"bad(route: handler(value: Int, state: Int >= 1) (answer: Int)) (answer: Int) { answer = reduce(route, [1, 2], 1) }",
"bad(route: handler(value: Int, context: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [1], 0) }",
] {
assert_eq!(checked(body).unwrap_err().code, "C011", "{body}");
}
checked("valid(route: handler(value: Int, state: Int >= 1) (answer: Int >= 1)) (answer: Int >= 1) { answer = reduce(route, [1, 2], 1) }").unwrap();
checked("valid(route: handler(value: Int >= 0 and <= 255) (answer: Int), values: Bytes) (answer: List<Int>) { answer = each(route, values) }").unwrap();
}
#[test]
fn failing_collection_inputs_do_not_produce_invalid_values() {
let prefix =
"type Problem { error Failed }\nabortOperation() (answer: Int) { fail(Problem.Failed) }\n";
checked(&(prefix.to_owned() + "apply(route: handler(value: Int, context: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [1], abortOperation()) }")).unwrap();
checked("type Problem { error Failed }\nabortOperation(value: Int, state: Int) (answer: Int) { fail(Problem.Failed) }\napply(route: handler(value: Int, state: Int >= 1) (answer: Int >= 1)) (answer: Int >= 1) { answer = reduce(route, [1], 1) }\nentry() (answer: Int >= 1) { answer = apply(abortOperation) }").unwrap();
}
+76
View File
@@ -0,0 +1,76 @@
use super::checked;
mod handler_range;
mod range;
#[test]
fn decimal_and_float_clauses_cover_their_actual_value_domains() {
for text in [
"pick(value: Decimal < 0.0) (answer: Int) { answer = -1 }\npick(value: Decimal >= 0.0) (answer: Int) { answer = 1 }",
"pick(value: other) (answer: Int) { answer = 1 }\npick(value: -0.0) (answer: Int) { answer = 0 }",
"pick(value: null) (answer: Int) { answer = 0 }\npick(value: Decimal < 0.0) (answer: Int) { answer = -1 }\npick(value: other) (answer: Int) { answer = 1 }",
"pick(value: 1.0e0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 0 }",
"pick(value: null) (answer: Int) { answer = 0 }\npick(value: 0.0e0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 2 }",
"pick(value: Int <= 9223372036854775807) () {}",
"pick(value: Int >= -9223372036854775808) () {}",
] {
checked(text).unwrap_or_else(|error| panic!("{text}\n{error:?}"));
}
for text in [
"pick(value: Decimal < 0.0) () {}",
"pick(value: Decimal <= 0.0) () {}\npick(value: Decimal >= 0.0) () {}",
"pick(value: 1.0) () {}\npick(value: 1.00) () {}\npick(value: other) () {}",
"pick(value: 0.0e0) () {}\npick(value: -0.0e0) () {}\npick(value: other) () {}",
"pick(value: 1.0e0) () {}",
"pick(value: Float) () {}\npick(value: other) () {}",
"pick(value: Int > 9223372036854775807) () {}\npick(value: other) () {}",
] {
let error = checked(text).unwrap_err();
assert_eq!(error.code, "C009", "{text}: {error:?}");
}
}
#[test]
fn decimal_partitions_use_exact_successors_at_precision_and_exponent_limits() {
for (left, right) in [
("1.0", "1.000000000000000000000000000000001"),
("0.0", "1e-6176"),
("-1e-6176", "0.0"),
(
"9.999999999999999999999999999999998e6144",
"9.999999999999999999999999999999999e6144",
),
] {
let text = format!(
"pick(value: Decimal <= {left}) () {{}}\npick(value: Decimal >= {right}) () {{}}"
);
checked(&text).unwrap_or_else(|error| panic!("{text}: {error:?}"));
}
for text in [
"pick(value: Decimal <= 1.0) () {}\npick(value: Decimal >= 1.000000000000000000000000000000002) () {}",
"pick(value: Decimal > 9.999999999999999999999999999999999e6144) () {}\npick(value: other) () {}",
"pick(value: Decimal < -9.999999999999999999999999999999999e6144) () {}\npick(value: other) () {}",
] {
assert_eq!(checked(text).unwrap_err().code, "C009", "{text}");
}
}
#[test]
fn numeric_pattern_validation_rejects_invalid_bounds_and_mixed_types() {
for (text, code) in [
("pick(value: 9223372036854775808) () {}", "C008"),
("pick(value: 1e9999) () {}", "C008"),
(
"pick(value: Decimal < 0.12345678901234567890123456789012345) () {}",
"C008",
),
("pick(value: Int < 1.5) () {}", "C008"),
("pick(value: Float < 1.0e0) () {}", "C005"),
("pick(value: Decimal? < 1.0) () {}", "C005"),
("pick(value: 1.0) () {}\npick(value: Float) () {}", "C005"),
] {
assert_eq!(checked(text).unwrap_err().code, code, "{text}");
}
assert!(checked("pick(value: Int >= 1 and) () {}").is_err());
assert!(checked("pick(value: Decimal < 1.0 and) () {}").is_err());
}
+206
View File
@@ -0,0 +1,206 @@
use super::super::checked;
const PORT: &str = "type Port { value: Int >= 1 and <= 65535 }\n";
#[test]
fn nonreturning_values_propagate_failure_before_range_boundaries() {
let source = PORT.to_owned()
+ "type Problem { error Failed }\nabortOperation() (answer: Int) { fail(Problem.Failed) }\nmake() (answer: Port) { answer = Port { value = abortOperation() } }\n";
checked(&source).unwrap();
checked(&(source + "apply(route: handler() (answer: Int >= 1)) (answer: Int >= 1) { answer = route() }\nentry() (answer: Int >= 1) { answer = apply(abortOperation) }")).unwrap();
}
#[test]
fn ranges_check_construction_outputs_and_every_write() {
for body in [
"make() (answer: Port) { answer = Port { value = 8080 } }",
"make(value: Int >= 1 and <= 65535) (answer: Port) { answer = Port { value = value } }\nmake(value: other) (answer: Port) { answer = Port { value = 8080 } }",
"make(value: Port) (answer: Int >= 1 and <= 65535) { answer = value.value }",
"make(value: Int < 1) (answer: Int >= 1) { answer = 1 }\nmake(value: other) (answer: Int >= 1) { answer = value }",
"make(value: Int < 1) (answer: Int >= 1 and <= 65535) { answer = 1 }\nmake(value: Int > 65535) (answer: Int >= 1 and <= 65535) { answer = 65535 }\nmake(value: other) (answer: Int >= 1 and <= 65535) { answer = value }",
] {
checked(&(PORT.to_owned() + body)).unwrap_or_else(|error| panic!("{body}: {error:?}"));
}
for body in [
"make() (answer: Port) { answer = Port { value = 0 } }",
"make(value: Int) (answer: Port) { answer = Port { value = value } }",
"make(value: Port) (answer: Port) { value.value = 65536\nanswer = value }",
"make(value: Int) (answer: Int >= 1) { answer = value }",
"make() (answer: Int >= 1) { answer = 0\nanswer = 1 }",
"type Outer { port: Port }\nmake(value: Outer) (answer: Outer) { value.port.value = 0\nanswer = value }",
] {
assert_eq!(
checked(&(PORT.to_owned() + body)).unwrap_err().code,
"C011",
"{body}"
);
}
}
#[test]
fn snapshots_survive_mutation_without_retaining_stale_facts() {
let prefix = "type Row { value: Int }\ntype Pair { left: Row\nright: Row }\n";
let body = "row = Row { value = 3 }\npair = Pair { left = row, right = row }\nsaved = pair\npair.left.value = 0\nrow.value = -1\n";
for value in ["saved.left.value", "saved.right.value", "pair.right.value"] {
checked(&format!(
"{prefix}make() (answer: Int >= 1) {{ {body}answer = {value} }}"
))
.unwrap();
}
for value in ["pair.left.value", "row.value"] {
assert_eq!(
checked(&format!(
"{prefix}make() (answer: Int >= 1) {{ {body}answer = {value} }}"
))
.unwrap_err()
.code,
"C011"
);
}
assert_eq!(checked("make(value: Int >= 1) (answer: Int >= 1) { value = 0\nanswer = value }\nmake(value: other) (answer: Int >= 1) { answer = 1 }").unwrap_err().code, "C011");
}
#[test]
fn choice_payloads_and_captured_outputs_preserve_contracts() {
let prefix = "type Result { Ready(value: Int >= 1)\nerror Failed(message: Text) }\n";
let consumer = "read(value: Result.Ready(number)) (answer: Int >= 1) recover(\"fallback\") { answer = number }\nread(value: Result.Failed(message)) (answer: Int >= 1) recover(\"fallback\") { answer = 1 }\n";
checked(
&(prefix.to_owned() + consumer + "make() (answer: Result) { answer = Result.Ready(1) }"),
)
.unwrap();
for value in ["0", "unknown"] {
assert_eq!(
checked(&format!(
"{prefix}make(unknown: Int) (answer: Result) {{ answer = Result.Ready({value}) }}"
))
.unwrap_err()
.code,
"C011"
);
}
checked(&format!("{prefix}produce() (answer: Int) {{ answer = 3 }}\nmake() (answer: Result) {{ answer = result(produce()) }}")).unwrap();
assert_eq!(checked(&format!("{prefix}produce(value: Int) (answer: Int) {{ answer = value }}\nmake(value: Int) (answer: Result) {{ answer = result(produce(value)) }}")).unwrap_err().code, "C011");
checked("pair() (small: Int >= 1 and <= 3, large: Int >= 5) { small = 2\nlarge = 8 }\nread() (answer: Int >= 5) { saved = pair()\nanswer = saved.large }").unwrap();
}
#[test]
fn arithmetic_proof_uses_checked_int_and_decimal_semantics() {
checked(
&(PORT.to_owned()
+ "step(value: Port) (answer: Int >= 2 and <= 65536) { answer = value.value + 1 }"),
)
.unwrap();
checked("step(value: Int >= -8 and <= 8) (answer: Int >= -16 and <= 16) { answer = value * -2 }\nstep(value: other) (answer: Int >= -16 and <= 16) { answer = 0 }").unwrap();
checked("type Rate { value: Decimal >= 0.0 and <= 1.0 }\nscale(value: Rate) (answer: Decimal >= 0.0 and <= 100.0) { answer = value.value * 100.0 }").unwrap();
checked("type Tiny { value: Decimal > 0.0 and <= 1e-6176 }\ncopy(value: Tiny) (answer: Decimal > 0.0) { answer = value.value }").unwrap();
checked("type Edge { value: Decimal > 1.0 and <= 1.000000000000000000000000000000001 }\ncopy(value: Edge) (answer: Decimal > 1.0) { answer = value.value }").unwrap();
checked("convert(value: Int >= 1 and <= 8) (answer: Decimal >= 1.0 and <= 8.0) { answer = value }\nconvert(value: other) (answer: Decimal >= 1.0 and <= 8.0) { answer = 1.0 }").unwrap();
for body in [
"read(value: Int) (answer: Int >= 0) { answer = value + 1 }",
"read(value: Int >= 1) (answer: Int >= 0) { answer = -value }\nread(value: other) (answer: Int >= 0) { answer = 0 }",
"read(value: Int >= 0) (answer: Int >= 0) { answer = value + 1 }\nread(value: other) (answer: Int >= 0) { answer = 0 }",
] {
assert_eq!(checked(body).unwrap_err().code, "C011", "{body}");
}
}
#[test]
fn invalid_empty_and_inconsistent_ranges_are_rejected() {
for fields in [
"value: Int > 9223372036854775807",
"value: Int < -9223372036854775808",
"value: Int > 1 and < 2",
"value: Decimal > 0.0 and < 1e-6176",
] {
assert_eq!(
checked(&format!("type Invalid {{ {fields} }}"))
.unwrap_err()
.code,
"C011"
);
}
for fields in [
"value: Float >= 1.0e0",
"value: Int? >= 1",
"value: Text >= 1",
] {
assert_eq!(
checked(&format!("type Invalid {{ {fields} }}"))
.unwrap_err()
.code,
"C005"
);
}
assert_eq!(
checked("type Invalid { value: Decimal >= 0.12345678901234567890123456789012345 }")
.unwrap_err()
.code,
"C008"
);
assert_eq!(checked("read(value: Int < 0) (answer: Int >= 1) { answer = 1 }\nread(value: other) (answer: Int >= 2) { answer = 2 }").unwrap_err().code, "C013");
checked("read(value: Int < 0) (answer: Int > 0) { answer = 1 }\nread(value: other) (answer: Int >= 1) { answer = 2 }").unwrap();
}
#[test]
fn range_proofs_remove_only_redundant_arithmetic_checks() {
let text = "type Port { value: Int >= 1 and <= 65535 }\nstep(value: Port) (answer: Int >= 2 and <= 65536) { answer = value.value + 1 }";
let mut plain = text.to_owned();
for bound in [">= 1 and <= 65535", ">= 2 and <= 65536"] {
plain = plain.replace(bound, &" ".repeat(bound.len()));
}
let source = |text: &str| dever::syntax::source::Source::new(0, "case.dever", text.into());
let emit = |text: &str| {
dever::compiler::emit::generate(
&checked(text).unwrap(),
&[source(text)],
&std::collections::BTreeSet::from([0]),
)
};
let bounded = emit(text);
let unbounded = emit(&plain);
assert!(bounded.contains("wrapping_add"));
assert!(!bounded.contains("checked_add"));
assert!(unbounded.contains("checked_add"));
assert!(!unbounded.contains("wrapping_add"));
}
#[test]
fn arithmetic_proofs_follow_field_updates_and_each_specialized_handler() {
let text = "type Row { value: Int }\ncopy(value: Int) (answer: Int) { answer = value }\nread(value: Int) (answer: Int) { row = Row { value = 1 }\nsaved = row\nrow.value = value\nknown = saved.value + 1\nunknown = row.value + 1\nanswer = known + unknown }\napply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) + 1 }\nfixed(value: Int) (answer: Int) { answer = 1 }\nfixedCall(value: Int) (answer: Int) { answer = apply(fixed, value) }\nunknownCall(value: Int) (answer: Int) { answer = apply(copy, value) }";
let program = checked(text).unwrap();
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
let emit = |name: &str| {
let index = program
.functions
.iter()
.position(|function| function.signature.name == name)
.unwrap();
dever::compiler::emit::generate(&program, std::slice::from_ref(&source), &[index].into())
};
let changed = emit("read");
assert!(changed.contains("wrapping_add"));
assert!(changed.contains("checked_add"));
assert!(emit("fixedCall").contains("wrapping_add"));
assert!(!emit("unknownCall").contains("wrapping_add"));
assert!(emit("unknownCall").contains("checked_add"));
}
#[test]
fn range_complement_proof_stops_at_its_work_budget() {
let mut source = String::new();
for value in 0..100 {
source.push_str(&format!(
"classify(value: {value}) (answer: Int) {{ answer = value }}\n"
));
}
source.push_str("classify(value: other) (answer: Int) { answer = value }");
checked(&source).unwrap();
for value in 100..200 {
source.push_str(&format!(
"\nclassify(value: {value}) (answer: Int) {{ answer = value }}"
));
}
let error = checked(&source).unwrap_err();
assert_eq!(error.code, "C015");
assert!(error.message.contains("range proof"), "{error:?}");
}
+156
View File
@@ -0,0 +1,156 @@
use super::checked;
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
pub(super) fn packages(files: &[(&str, &str)]) -> Result<Program, Diagnostic> {
let mut modules = Vec::new();
let mut identities = Vec::new();
for (index, (identity, text)) in files.iter().enumerate() {
let source = Source::new(index, format!("{identity}.dever"), (*text).into());
modules.push(dever::syntax::parse(&source)?);
identities.push((*identity).into());
}
check::check(modules, &identities)
}
#[test]
fn public_signatures_cannot_publish_private_types() {
for declaration in [
"public echo(value: Hidden) (answer: Hidden) { answer = value }",
"public echo(value: List<Hidden?>) (answer: List<Hidden?>) { answer = value }",
"public echo(value: Map<Text, List<Hidden>>) (answer: Map<Text, List<Hidden>>) { answer = value }",
"public echo(value: MapEntry<Text, Hidden>) (answer: MapEntry<Text, Hidden>) { answer = value }",
"public apply(action: handler(value: Hidden) (answer: Hidden)) () {}",
"public type Visible { nested: Hidden }",
"public type Visible { Wrapped(value: Hidden) }",
] {
let text = format!("type Hidden {{ value: Int }}\n{declaration}");
assert_eq!(checked(&text).unwrap_err().code, "C006", "{text}");
}
assert!(checked("public type Visible { value: Int }\npublic echo(value: List<Visible?>) (answer: List<Visible?>) { answer = value }").is_ok());
}
#[test]
fn public_failures_cannot_leak_private_error_identity() {
for call in ["fail(Hidden.Failed(\"private\"))", "hidden()"] {
let text = format!(
"type Hidden {{ error Failed(message: Text) }}\nhidden() () {{ fail(Hidden.Failed(\"private\")) }}\npublic exposed() () {{ {call} }}"
);
let error = checked(&text).unwrap_err();
assert_eq!(error.code, "C006", "{error:?}");
}
assert!(checked("public type Problem { error Failed(message: Text) }\nprivateFailure() () { fail(Problem.Failed(\"public\")) }\npublic exposed() () { privateFailure() }").is_ok());
assert_eq!(
checked("public apply(action: handler(value: Map<Float, Int>) ()) () {}")
.unwrap_err()
.code,
"C005"
);
}
#[test]
fn package_cycles_are_rejected_without_rejecting_static_callbacks() {
for files in [
[
(
"left",
"public type A { value: Int }\npublic make() () { local: right.B? = null }",
),
(
"right",
"public type B { value: Int }\npublic make() () { local: left.A? = null }",
),
],
[
(
"left",
"public first() (answer: Int) { answer = right.second() }\npublic last() (answer: Int) { answer = 1 }",
),
(
"right",
"public second() (answer: Int) { answer = left.last() }",
),
],
[
(
"left",
"public type A { value: Int }\npublic fromRight(value: right.B) () {}",
),
(
"right",
"public type B { value: Int }\npublic fromLeft(value: left.A) () {}",
),
],
[
(
"left",
"public type A { value: Int }\npublic make() () { local = right.B { value = 1 } }",
),
(
"right",
"public type B { value: Int }\npublic make() () { local = left.A { value = 1 } }",
),
],
] {
assert_eq!(packages(&files).unwrap_err().code, "C007");
}
let files = [
(
"client",
"privateDouble(value: Int) (answer: Int) { answer = value * 2 }\nmain() (answer: Int) { answer = helper.apply(privateDouble, 4) }",
),
(
"helper",
"public apply(action: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = action(value) }",
),
];
assert!(packages(&files).is_ok());
}
#[test]
fn cross_package_names_and_callbacks_obey_visibility() {
let helper = "type Hidden { value: Int }\nprivateRead(value: Int) (answer: Int) { answer = value }\npublic type Visible { value: Int }\npublic read(value: Int) (answer: Int) { answer = privateRead(value) }";
for caller in [
"main() (answer: Int) { answer = helper.privateRead(1) }",
"main() (answer: List<Int>) { answer = each(helper.privateRead, [1]) }",
"main(value: helper.Hidden) () {}",
"main() () { value = helper.Hidden { value = 1 } }",
] {
assert_eq!(
packages(&[("main", caller), ("helper", helper)])
.unwrap_err()
.code,
"C006"
);
}
assert!(packages(&[("main", "main() (answer: helper.Visible) { answer = helper.Visible { value = helper.read(3) } }"), ("helper", helper)]).is_ok());
}
#[test]
fn private_fields_reject_foreign_reads_writes_and_construction() {
let vault = "public type Session { private key: Text\nlabel: Text }\npublic type Envelope { value: Session }";
for caller in [
"read(value: vault.Session) (answer: Text) { answer = value.key }",
"write(value: vault.Session) () { value.key = \"changed\" }",
"make() (answer: vault.Session) { answer = vault.Session { key = \"hidden\", label = \"name\" } }",
"make() (answer: vault.Session) { answer = vault.Session { label = \"name\" } }",
"read(value: vault.Envelope) (answer: Text) { answer = value.value.key }",
"write(value: vault.Envelope) () { value.value.key = \"changed\" }",
] {
let error = packages(&[("main", caller), ("vault", vault)]).unwrap_err();
assert_eq!(error.code, "C006", "{caller}: {error:?}");
assert!(error.message.contains("field is private"), "{error:?}");
}
assert!(packages(&[("main", "rename(value: vault.Session) (answer: vault.Session) { value.label = \"new\"\nanswer = value }"), ("vault", vault)]).is_ok());
}
#[test]
fn private_record_fields_can_hide_types_but_choice_payloads_cannot() {
assert!(checked("type Hidden { value: Int }\npublic type Visible { private hidden: Hidden\nlabel: Text }\npublic make() (answer: Visible) { answer = Visible { hidden = Hidden { value = 7 }, label = \"name\" } }\npublic read(value: Visible) (answer: Int) { answer = value.hidden.value }").is_ok());
assert_eq!(
checked("public type Choice { Ready(private value: Int) }")
.unwrap_err()
.code,
"C005"
);
}
+127
View File
@@ -0,0 +1,127 @@
use super::{ADAPTER, APP, PORT, packages};
#[test]
fn roles_cannot_call_around_the_port_boundary() {
for call in [
"app.read(value)",
"domain.read(value)",
"port.read(value)",
"foreign.library.read(value)",
"shop.account.adapter.other.helper(value)",
] {
let adapter = format!("port.read(value: Int) (answer: Int) {{ answer = {call} }}");
let error = packages(&[
("shop.account.app", APP),
(
"shop.account.domain",
"read(value: Int) (answer: Int) { answer = value }",
),
("shop.account.port", PORT),
("shop.account.adapter.local", &adapter),
(
"foreign.library",
"public read(value: Int) (answer: Int) { answer = value }",
),
])
.unwrap_err();
assert!(
matches!(error.code, "C004" | "C005" | "C006"),
"{call}: {error:?}"
);
}
for caller in ["shop.account.domain", "shop.other.app", "foreign"] {
let error = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER),
(
caller,
"entry(value: Int) (answer: Int) { answer = shop.account.port.read(value) }",
),
])
.unwrap_err();
assert_eq!(error.code, "C006", "{caller}: {error:?}");
}
for call in [
"adapter.helper(value)",
"adapter.port.read(value)",
"shop.account.adapter.port.read(value)",
] {
assert!(
packages(&[
("shop.account.app", &APP.replace("port.read(value)", call)),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER)
])
.is_err(),
"{call}"
);
}
}
#[test]
fn only_app_types_in_the_complete_contract_graph_are_visible() {
let app = "type Problem { error Missing(message: Text) }\ntype Person { name: Text }\ntype Unrelated { number: Int }\nread(value: Person) (answer: Text) { answer = port.read(port.Row { person = value }) }";
let port = "type Row { person: app.Person }\nread(value: Row) (answer: Text) fails app.Problem";
let adapter = "port.read(value: port.Row) (answer: Text) { answer = label(value.person) }\nlabel(value: app.Person) (answer: Text) { answer = value.name }";
assert!(
packages(&[
("shop.account.adapter", adapter),
("shop.account.port", port),
("shop.account.app", app)
])
.is_ok()
);
for bad in [
format!("{adapter}\nunused(value: app.Unrelated) () {{}}"),
adapter.replace(
"answer = label(value.person)",
"hidden = app.Unrelated { number = 1 }\nanswer = label(value.person)",
),
format!("{adapter}\ntype Hidden {{ value: List<app.Unrelated> }}"),
] {
assert_eq!(
packages(&[
("shop.account.adapter", &bad),
("shop.account.port", port),
("shop.account.app", app)
])
.unwrap_err()
.code,
"C006"
);
}
let bad = format!("{port}\ntype Unused {{ value: app.Unrelated }}");
assert_eq!(
packages(&[
("shop.account.app", app),
("shop.account.port", &bad),
("shop.account.adapter", adapter)
])
.unwrap_err()
.code,
"C006"
);
}
#[test]
fn adapter_cannot_read_another_ports_private_dto() {
let error = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
(
"shop.account.port.other",
"type Hidden { value: Int }\nwrite(value: Int) () fails app.Problem",
),
(
"shop.account.adapter.first",
&format!("{ADAPTER}\nunused(value: port.other.Hidden) () {{}}"),
),
(
"shop.account.adapter.second",
"port.other.write(value: Int) () {}",
),
])
.unwrap_err();
assert_eq!(error.code, "C006");
}
+125
View File
@@ -0,0 +1,125 @@
use super::signature;
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
fn packages(files: &[(&str, &str)]) -> Result<Program, Diagnostic> {
let mut modules = Vec::new();
let mut identities = Vec::new();
for (index, (identity, text)) in files.iter().enumerate() {
let path = if *identity == "dever.io" {
"lib/dever/io.dever"
} else {
"case.dever"
};
modules.push(dever::syntax::parse(&Source::new(
index,
path,
(*text).into(),
))?);
identities.push((*identity).into());
}
check::check(modules, &identities)
}
const IO: &str = include_str!("../../../../lib/dever/io.dever");
const PORT: &str = "open(path: Text) (file: dever.system.File) fails dever.io.OpenResult";
const ADAPTER: &str = "port.open(path: Text) (file: dever.system.File) { file = open(path) }\nopen(path: Text) (file: dever.system.File) { file = dever.io.open(path) }";
#[test]
fn boundary_retains_real_effects_without_permitting_unmediated_io() {
let app = "open(path: Text) (file: dever.system.File) { file = port.open(path) }";
let program = packages(&[
("shop.files.app", app),
("shop.files.port", PORT),
("shop.files.adapter", ADAPTER),
("dever.io", IO),
])
.unwrap();
let effects = signature(&program, "open").effects;
assert!(effects.file && effects.blocking && !effects.unmediated_io);
for body in [
"file = dever.io.open(path)",
"old = port.open(path)\nfile = dever.io.open(path)",
"files = each(dever.io.open, [path])\nfile = port.open(path)",
"old = result(dever.io.open(path))\nfile = port.open(path)",
] {
let app = format!("open(path: Text) (file: dever.system.File) {{ {body} }}");
assert!(
packages(&[
("shop.files.app", &app),
("shop.files.port", PORT),
("shop.files.adapter", ADAPTER),
("dever.io", IO)
])
.is_err(),
"{body}"
);
}
let pure = app.replace(" {", " pure {");
assert_eq!(
packages(&[
("shop.files.app", &pure),
("shop.files.port", PORT),
("shop.files.adapter", ADAPTER),
("dever.io", IO)
])
.unwrap_err()
.code,
"C013"
);
let scheduled = app.replace("port.open(path)", "blocking(port.open(path))");
assert!(
packages(&[
("shop.files.app", &scheduled),
("shop.files.port", PORT),
("shop.files.adapter", ADAPTER),
("dever.io", IO)
])
.is_ok()
);
let asynchronous = app.replace(
"file = port.open(path)",
"other = blocking(port.open(path))\nfile = port.open(path)",
);
assert_eq!(
packages(&[
("shop.files.app", &asynchronous),
("shop.files.port", PORT),
("shop.files.adapter", ADAPTER),
("dever.io", IO)
])
.unwrap_err()
.code,
"C013"
);
}
#[test]
fn selection_cannot_hide_blocking_candidate_effects() {
let app = "open(path: Text) (file: dever.system.File) { file = port.open(path) }";
let worker =
"port.open(path: Text) (file: dever.system.File) { file = blocking(dever.io.open(path)) }";
assert_eq!(
packages(&[
("shop.files.app", app),
("shop.files.port", PORT),
("shop.files.adapter.inline", ADAPTER),
("shop.files.adapter.worker", worker),
("dever.io", IO),
])
.unwrap_err()
.code,
"C013"
);
let program = packages(&[
("shop.files.app", app),
("shop.files.port", PORT),
("shop.files.adapter.first", worker),
("shop.files.adapter.second", worker),
("dever.io", IO),
])
.unwrap();
let effects = signature(&program, "open").effects;
assert!(effects.file && effects.suspend && effects.configuration);
assert!(!effects.blocking && !effects.unmediated_io);
}
+216
View File
@@ -0,0 +1,216 @@
use super::package::packages;
use dever::compiler::model::{Node, Program, Signature, Statement};
mod boundary;
mod effects;
mod settings;
const APP: &str = "type Problem { error Missing(message: Text)\n error Denied(code: Int) }\nread(value: Int) (answer: Int) { answer = port.read(value) }";
const PORT: &str = "read(value: Int) (answer: Int) fails app.Problem";
const ADAPTER: &str = "port.read(value: Int) (answer: Int) { answer = helper(value) }\nhelper(value: Int) (answer: Int) { answer = value + 1 }";
fn signature<'program>(program: &'program Program, name: &str) -> &'program Signature {
&program
.functions
.iter()
.find(|function| function.signature.name == name)
.unwrap()
.signature
}
#[test]
fn port_alias_binds_directly_and_preserves_the_declared_error_set() {
let program = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER),
("shop.account.api", "cmd read = app.read"),
])
.unwrap();
assert_eq!(program.functions.len(), 3);
let app = &program.functions[program.commands[0].function];
let Statement::Assign { value, .. } = &app.clauses[0].body[0] else {
panic!()
};
let Node::Call(index, _) = value.kind else {
panic!()
};
assert_eq!(program.functions[index].signature.name, "port.read");
assert_eq!(app.signature.failures.len(), 2);
assert_eq!(
app.signature.failures,
program.functions[index].signature.failures
);
assert!(!app.signature.effects.unmediated_io);
assert!(program.api_snapshot().contains("Problem.Denied"));
}
#[test]
fn topics_dtos_static_handlers_and_adapter_clauses_share_existing_type_checks() {
let program = packages(&[
(
"shop.account.app",
"type Problem { error Missing(message: Text) }\nread(value: Int) (answer: Int) { row = port.mail.Row { value = value }\nanswer = apply(port.mail.read, row) }\napply(action: handler(row: port.mail.Row) (answer: Int), row: port.mail.Row) (answer: Int) { answer = action(row) }",
),
(
"shop.account.port.mail",
"type Row { value: Int }\nread(row: Row) (answer: Int) fails app.Problem",
),
(
"shop.account.adapter.local",
"port.mail.read(row: port.mail.Row) (answer: Int) { answer = row.value }",
),
]);
// App 的公开 handler 参数不能泄漏 Port DTO;在领域内经普通 App 函数也不能绕过。
assert_eq!(program.unwrap_err().code, "C006");
let program = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", "port.read(value: Int < 0) (answer: Int) { fail(app.Problem.Missing(\"negative\")) }\nport.read(value: other) (answer: Int) { answer = value }"),
]).unwrap();
assert_eq!(signature(&program, "read").failures.len(), 2);
assert!(packages(&[
("shop.account.app", "type Problem { error Missing(message: Text) }\nread(value: Int) (answer: Int) { row = port.mail.Row { value = value }\nanswer = port.mail.read(row) }"),
("shop.account.port.mail", "type Row { value: Int }\nread(row: Row) (answer: Int) fails app.Problem"),
("shop.account.adapter.local", "port.mail.read(row: port.mail.Row) (answer: Int) { answer = row.value }"),
]).is_ok());
assert!(
packages(&[
(
"shop.account.app",
&APP.replace("port.read(value)", "sum(each(port.read, [value]))")
),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER),
])
.is_ok()
);
}
#[test]
fn ports_reject_missing_mismatched_and_partial_implementations() {
for adapter in [
"port.read(wrong: Int) (answer: Int) { answer = wrong }",
"port.read(value: Text) (answer: Int) { answer = 1 }",
"port.read(value: Int) (wrong: Int) { wrong = value }",
"port.read(value: Int, action: handler() ()) (answer: Int) { answer = value }",
"port.unknown(value: Int) (answer: Int) { answer = value }",
"helper(value: Int) (answer: Int) { answer = value }",
] {
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", adapter)
])
.is_err(),
"{adapter}"
);
}
assert!(packages(&[("shop.account.app", APP), ("shop.account.port", PORT)]).is_err());
assert!(
packages(&[
("shop.account.app", APP),
(
"shop.account.port",
&format!("{PORT}\nwrite(value: Int) () fails app.Problem")
),
("shop.account.adapter.first", ADAPTER),
(
"shop.account.adapter.second",
"port.write(value: Int) () {}"
),
])
.unwrap_err()
.message
.contains("complete Port")
);
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter.first", ADAPTER),
("shop.account.adapter.second", ADAPTER),
])
.is_ok()
);
}
#[test]
fn port_declarations_require_plain_types_and_public_explicit_errors() {
for port in [
"read(value: Int) (answer: Int) { answer = value }",
"read(value: Int < 0) (answer: Int) fails app.Problem",
"read(value: other) (answer: Int) fails app.Problem",
"read(value: handler() ()) (answer: Int) fails app.Problem",
"read(value: Int) (answer: Int > 0) fails app.Problem",
"read(value: Int) (answer: Int) pure fails app.Problem",
"read(value: Int) (answer: Int) recover(\"ignore\") fails app.Problem",
"read(value: Int) (answer: Int) fails Int",
"read(value: Int) (answer: Int) fails app.Problem?",
"read(value: Int) (answer: Int) fails app.Record",
"read(value: Int) (answer: Int) fails app.State",
"type Local { error Failed }\nread(value: Int) (answer: Int) fails Local",
"public read(value: Int) (answer: Int) fails app.Problem",
] {
let app = format!("{APP}\ntype Record {{ value: Int }}\ntype State {{ Ready }}");
assert!(
packages(&[
("shop.account.app", &app),
("shop.account.port", port),
("shop.account.adapter", ADAPTER)
])
.is_err(),
"{port}"
);
}
assert!(packages(&[("main", "read(value: Int) (answer: Int) fails Problem")]).is_err());
assert_eq!(
packages(&[
("shop.account.app", APP),
("shop.account.port", &format!("{PORT}\n{PORT}")),
("shop.account.adapter", ADAPTER)
])
.unwrap_err()
.code,
"C002"
);
}
#[test]
fn implementations_cannot_propagate_undeclared_errors() {
let adapter = "type Private { error Other }\nport.read(value: Int) (answer: Int) { fail(Private.Other()) }";
assert_eq!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", adapter)
])
.unwrap_err()
.code,
"C012"
);
let app = "type Problem { error Missing(message: Text)\nerror Denied(code: Int) }\ntype ReadResult { Ready(value: Int)\nerror Failed(problem: Problem) }\nread(value: Int) (answer: ReadResult) { answer = result(port.read(value)) }";
assert!(
packages(&[
("shop.account.app", app),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER)
])
.is_ok()
);
let app = app.replace(
"answer = result(port.read(value))",
"lost = result(port.read(value))\nanswer = ReadResult.Ready(value)",
);
assert_eq!(
packages(&[
("shop.account.app", &app),
("shop.account.port", PORT),
("shop.account.adapter", ADAPTER)
])
.unwrap_err()
.code,
"C012"
);
}
+194
View File
@@ -0,0 +1,194 @@
use super::{APP, PORT, packages, signature};
#[test]
fn external_worker_secrets_belong_only_in_settings() {
let adapter = "setting { token: Secret }\nexternal exec \"worker\" {}";
for kind in [
"Text",
"Secret",
"Secret?",
"List<Secret>",
"app.Credentials",
] {
let app = format!(
"type Problem {{ error Failed(message: Text) }}\ntype Credentials {{ tokens: List<Secret?> }}\nread(value: {kind}) (answer: Int) {{ answer = port.read(value) }}"
);
let port = format!("read(value: {kind}) (answer: Int) fails app.Problem");
let result = packages(&[
("shop.account.app", &app),
("shop.account.port", &port),
("shop.account.adapter", adapter),
]);
if kind == "Text" {
assert!(result.is_ok(), "{result:?}");
} else {
let error = result.unwrap_err();
assert!(
error.message.contains("Secret belongs only in setting"),
"{kind}: {error:?}"
);
}
}
}
#[test]
fn settings_are_private_readonly_typed_values() {
let adapter = "setting { offset: Int\ntoken: Secret }\nport.read(value: Int) (answer: Int) { answer = helper(value) }\nhelper(value: Int) (answer: Int) pure { answer = value + setting.offset }";
let program = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", adapter),
])
.unwrap();
assert!(signature(&program, "read").effects.configuration);
assert!(signature(&program, "helper").pure);
assert_eq!(signature(&program, "read").failures.len(), 2);
for body in [
"setting.offset = value\nanswer = value",
"setting = value\nanswer = value",
"answer = setting.missing",
"answer = setting.offset()",
"answer = setting.token",
] {
let bad = format!(
"setting {{ offset: Int\ntoken: Secret }}\nport.read(value: Int) (answer: Int) {{ {body} }}"
);
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", &bad)
])
.is_err(),
"{body}"
);
}
let empty =
"setting {}\nport.read(value: Int) (answer: Int) { setting = value\nanswer = value }";
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", empty)
])
.is_err()
);
}
#[test]
fn settings_reject_wrong_roles_invalid_wire_fields_and_shadowing() {
for owner in [
"main",
"shop.account.app",
"shop.account.port",
"shop.account.domain",
] {
assert!(
packages(&[(owner, "setting { offset: Int }")]).is_err(),
"{owner}"
);
}
for setting in [
"setting { Ready }",
"setting { private offset: Int }",
"setting { offset: Int > 0 }",
"setting { file: dever.system.File }",
"setting { values: Map<Text, Secret> }",
"setting { offset: Int }\nsetting { token: Secret }",
"setting { missing: Unknown }",
] {
let adapter =
format!("{setting}\nport.read(value: Int) (answer: Int) {{ answer = value }}");
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", &adapter)
])
.is_err(),
"{setting}"
);
}
for helper in [
"helper(setting: Int) () {}",
"helper() (setting: Int) { setting = 1 }",
] {
let adapter = format!(
"setting {{ offset: Int }}\nport.read(value: Int) (answer: Int) {{ answer = value }}\n{helper}"
);
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter", &adapter)
])
.is_err()
);
}
}
#[test]
fn unused_adapter_candidates_still_enforce_failures_and_complete_contracts() {
let valid = "port.read(value: Int) (answer: Int) { answer = value }";
let invalid = "type Private { error Failed }\nport.read(value: Int) (answer: Int) { fail(Private.Failed()) }";
assert_eq!(
packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter.local", valid),
("shop.account.adapter.remote", invalid)
])
.unwrap_err()
.code,
"C012"
);
let extra_port = format!("{PORT}\nwrite(value: Int) () fails app.Problem");
let complete = format!("{valid}\nport.write(value: Int) () {{}}");
assert!(
packages(&[
("shop.account.app", APP),
("shop.account.port", &extra_port),
("shop.account.adapter.local", &complete),
("shop.account.adapter.remote", valid)
])
.is_err()
);
}
#[test]
fn selection_keeps_all_candidate_effects_and_failure_identity() {
let quiet = "setting { offset: Int }\nport.read(value: Int) (answer: Int) { answer = value + setting.offset }";
let scheduled = "port.read(value: Int) (answer: Int) { answer = wait(run(helper(value))) }\nhelper(value: Int) (answer: Int) { answer = value }";
let program = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter.local", quiet),
("shop.account.adapter.remote", scheduled),
])
.unwrap();
let effects = signature(&program, "read").effects;
assert!(effects.configuration && effects.suspend && effects.schedule);
let pure = APP.replace("(answer: Int) {", "(answer: Int) pure {");
assert_eq!(
packages(&[
("shop.account.app", &pure),
("shop.account.port", PORT),
("shop.account.adapter.local", quiet),
("shop.account.adapter.remote", scheduled),
])
.unwrap_err()
.code,
"C013"
);
let error = "port.read(value: Int) (answer: Int) { fail(app.Problem.Missing(\"missing\")) }";
let program = packages(&[
("shop.account.app", APP),
("shop.account.port", PORT),
("shop.account.adapter.local", quiet),
("shop.account.adapter.remote", error),
])
.unwrap();
assert_eq!(signature(&program, "read").failures.len(), 2);
assert!(signature(&program, "read").effects.configuration);
assert!(!program.api_snapshot().contains("setting.offset"));
}
+75
View File
@@ -0,0 +1,75 @@
use dever::{
compiler::{check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
("text", include_str!("../../../lib/text.dever")),
("int", include_str!("../../../lib/int.dever")),
("decimal", include_str!("../../../lib/decimal.dever")),
("float", include_str!("../../../lib/float.dever")),
("math", include_str!("../../../lib/math.dever")),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (_, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("library{index}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn text_and_numeric_standard_calls_remain_typed_and_effect_free() {
let program = linked("main() (answer: Bool) pure { text = text.join([int.toText(42), decimal.toText(decimal.round(1.25, 1))], \"/\")\n answer = text.contains(text, \"42\") and math.isNan(math.sqrt(-1e0)) }").unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert_eq!(main.signature.effects, Default::default());
for source in [
"main() () { value = int.parse(1) }",
"main() () { value = text.slice(\"x\", 1.0, 2) }",
"main() () { value = math.sqrt(\"9\") }",
"main() (answer: Int) { answer = int.parse(\"1\") }",
] {
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
}
}
#[test]
fn user_sources_cannot_call_standard_primitive_boundaries() {
for name in [
"textTrim",
"textJoin",
"intParse",
"decimalRound",
"floatFromInt",
"floatSqrt",
] {
assert_eq!(
linked(&format!("main() () {{ value = dever.system.{name}(1) }}"))
.unwrap_err()
.code,
"C006",
"{name}"
);
}
}
+212
View File
@@ -0,0 +1,212 @@
use super::checked;
#[test]
fn literal_collections_select_values_without_building_containers() {
for text in [
"main(value: Text) (answer: Text?) { answer = get({ \"left\" = value }, \"left\") }",
"main(value: Text?) (answer: Text?) { answer = get({ \"雪\" = value }, \"雪\") }",
"main(value: Text) (answer: Text?) { answer = get({ \"left\" = value }, \"missing\") }",
"main(value: Text) (answer: Text?) { answer = first([value]) }",
"main(value: Text?) (answer: Text?) { answer = first([value]) }",
] {
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
let mut program = checked(text).unwrap();
program.functions[0].signature.name = "renamed".into();
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
assert!(!generated.contains("Map::with_capacity"), "{generated}");
assert!(!generated.contains("vec!["), "{generated}");
assert!(generated.contains(".to_owned()"), "{generated}");
}
for (text, constructor) in [
(
"main(value: Text, key: Text) (answer: Text?) { answer = get({ \"left\" = value }, key) }",
"Map::with_capacity",
),
(
"main(value: Text, key: Text) (answer: Text?) { answer = get({ key = value }, \"left\") }",
"Map::with_capacity",
),
(
"main(value: dever.system.File) (answer: dever.system.File?) { answer = get({ \"left\" = value }, \"left\") }",
"Map::with_capacity",
),
(
"main(value: dever.system.File) (answer: dever.system.File?) { answer = first([value]) }",
"vec![",
),
] {
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
let program = checked(text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
assert!(generated.contains(constructor), "{generated}");
}
}
#[test]
fn contains_checks_comparable_items_without_consuming_error_obligations() {
for source in [
"main(values: List<Text>, target: Text) (found: Bool) { found = contains(values, target) }",
"main() (found: Bool) { found = contains([], 1) }",
"type Row { id: Int }\nmain(rows: List<Row>, row: Row) (found: Bool) { found = contains(rows, row) }",
] {
checked(source).unwrap();
}
for source in [
"main() (found: Bool) { found = contains([1], true) }",
"main() (found: Bool) { found = contains([], null) }",
"type Outcome { Done\nerror Failed }\nmain() (found: Bool) { found = contains([Outcome.Failed], Outcome.Done) }",
] {
assert!(checked(source).is_err(), "{source}");
}
}
#[test]
fn record_construction_borrows_before_moving_owned_values() {
for fields in [
"first = owned\n second = length(owned)",
"second = length(owned)\n first = owned",
] {
let text = format!(
"type Row {{ first: Text\n second: Int }}\nmain(input: Text) (answer: Row) {{ owned = input + \"!\"\n answer = Row {{ {fields} }} }}"
);
let source = dever::syntax::source::Source::new(0, "case.dever", text);
let program = checked(&source.text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
assert_eq!(generated.matches(".clone()").count(), 0, "{generated}");
assert!(!generated.contains(".to_owned()"), "{generated}");
}
let text = "type Row { first: Text\n second: Text }\nmain(input: Text) (answer: Row) { owned = input + \"!\"\n answer = Row { first = owned\n second = owned } }";
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
let program = checked(text).unwrap();
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
assert_eq!(generated.matches(".clone()").count(), 1, "{generated}");
}
#[test]
fn map_keys_and_literal_identity_are_checked() {
for kind in [
"Float",
"Decimal",
"Int?",
"List<Int>",
"Map<Int, Int>",
"Row",
"Payload",
] {
let source = format!(
"type Row {{ count: Int }}\ntype Payload {{ Value(value: Int) }}\nunused(values: Map<{kind}, Int>) () {{}}"
);
assert_eq!(checked(&source).unwrap_err().code, "C005", "{kind}");
}
for body in [
"value = { \"a\" = 1\n \"a\" = 2 }",
"value = { 1 + 1 = 1\n 2 = 2 }",
"value = { Key.First = 1\n Key.First = 2 }",
"key = \"a\"\n alias = key\n value = { alias = 1\n \"a\" = 2 }",
"value = { input = 1\n input = 2 }",
] {
let source = format!("type Key {{ First\n Second }}\ninvalid(input: Text) () {{ {body} }}");
assert_eq!(checked(&source).unwrap_err().code, "C002", "{body}");
}
assert!(checked("valid(input: Text, other: Text) (values: Map<Text, Int>) { alias = input\n input = other\n values = { alias = 1\n input = 2 } }").is_ok());
assert_eq!(
checked("type Node { children: Map<Int, Node> }")
.unwrap_err()
.code,
"C007"
);
assert_eq!(
checked("type Bad { values: Map<Float, Int> }")
.unwrap_err()
.code,
"C005"
);
}
#[test]
fn access_types_empty_contexts_and_nullable_results_are_checked() {
for source in [
"main() (values: List<Int>) { values = append([], 1) }",
"main() (values: Map<Text, Int>) { values = put({}, \"a\", 1) }",
"main() (value: Int?) { value = first([]) }",
"main(values: Map<Text, Int?>) (value: Int?) { value = get(values, \"a\") }",
"main(values: List<Int?>) (value: Int?) { value = first(values) }",
"main() (values: List<MapEntry<Text, Int>>) { values = entries({}) }",
"main(values: List<MapEntry<Text, Int>>) (result: List<MapEntry<Text, Int>>) { result = values }",
"main(value: MapEntry<Text, Int>) (result: Text) { result = value.key }",
] {
assert!(checked(source).is_ok(), "{source}");
}
for source in [
"main() () { values = {} }",
"main() () { values = { 1 = 2\n \"a\" = 3 } }",
"main() () { values = { 1 = 2\n 2 = true } }",
"main() () { values = put({}, 1.0, 1) }",
"main(values: Map<Text, Int>) () { value = get(values, 1) }",
"main(values: List<Int>) () { value = append(values, true) }",
"main(values: Bytes) () { value = append(values, 1) }",
"main(values: Map<Text, Int>) () { value = first(values) }",
"main(value: MapEntry<Text, Int>) () { field = value.missing }",
] {
assert!(checked(source).is_err(), "{source}");
}
}
const ERRORS: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\nconsume(input: Result.Ready(value)) () recover(\"discard explicitly in test\") {}\nconsume(input: Result.Failed(message)) () recover(\"discard explicitly in test\") {}\n";
#[test]
fn nested_collection_shapes_survive_error_free_records_and_calls() {
let types = "type Row { values: List<Int> }\ntype Box { row: Row }\ntype Choice { Row(value: Row)\n Empty }\n";
for body in [
"update(row: Row) (answer: List<Int>) { answer = append(row.values, 1) }",
"update(box: Box) (answer: List<Int>) { answer = append(box.row.values, 1) }",
"update(entry: MapEntry<Int, Row>) (answer: List<Int>) { answer = append(entry.value.values, 1) }",
"update(row: Row) (answer: List<Int>) { answer = append(row.values, 1) }\nupdate(row: null) (answer: List<Int>) { answer = [] }",
"update(choice: Choice.Row(row)) (answer: List<Int>) { answer = append(row.values, 1) }\nupdate(choice: Choice.Empty) (answer: List<Int>) { answer = [] }",
"choose(flag: true, row: Row) (answer: Row) { answer = row }\nchoose(flag: false, row: Row) (answer: Row) { answer = Row { values = [] } }\nupdate(flag: Bool, row: Row) (answer: List<Int>) { selected = choose(flag, row)\n answer = append(selected.values, 1) }",
] {
let result = checked(&(types.to_owned() + body));
assert!(result.is_ok(), "{body}: {result:?}");
}
}
#[test]
fn map_errors_cannot_be_discarded_by_lookup_or_mutation() {
let types = "type Result { Ready(value: Int)\n error Failed(message: Text) }\n";
for body in [
"bad(values: Map<Text, Result>) (answer: Int) { answer = length(values) }",
"bad(values: Map<Text, Result>) (answer: Result?) { answer = get(values, \"one\") }",
"bad(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = remove(values, \"one\") }",
"bad(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = put(values, \"one\", Result.Ready(1)) }",
"bad(values: List<Result>) (answer: Result?) { answer = first(values) }",
"bad() (answer: Result?) { answer = first([Result.Ready(1), Result.Failed(\"lost\")]) }",
"bad(value: MapEntry<Text, Result>) (answer: Text) { answer = value.key }",
] {
assert_eq!(
checked(&(types.to_owned() + body)).unwrap_err().code,
"C012",
"{body}"
);
}
for body in [
"valid(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = values }",
"valid(values: Map<Text, Result>) (answer: List<MapEntry<Text, Result>>) { answer = entries(values) }",
"valid(values: List<Result>) (answer: List<Result>) { answer = append(values, Result.Ready(1)) }",
"valid() (answer: Result?) { answer = first([Result.Failed(\"returned\")]) }",
"valid() (answer: Map<Text, Result>) { answer = put({}, \"one\", Result.Failed(\"returned\")) }",
] {
assert!(checked(&(types.to_owned() + body)).is_ok(), "{body}");
}
}
#[test]
fn complete_entries_traversal_handles_keys_values_and_nested_errors() {
let source = ERRORS.to_owned();
let source = source
+ "consumeEntry(entry: MapEntry<Text, Result>) () { consume(entry.value) }\nvalid(values: Map<Text, Result>) () { each(consumeEntry, entries(values)) }";
let result = checked(&source);
assert!(result.is_ok(), "{result:?}");
let source = "type Key { Fine\n error Failed }\nconsume(entry: MapEntry<Key, Int>) () recover(\"key error policy\") {}\nvalid(values: Map<Key, Int>) () { each(consume, entries(values)) }";
assert!(checked(source).is_ok());
assert_eq!(checked("type Key { Fine\n error Failed }\nbad(values: Map<Key, Int>) (answer: Int) { answer = length(values) }").unwrap_err().code, "C012");
}
+125
View File
@@ -0,0 +1,125 @@
use super::linked;
#[test]
fn async_streams_support_typed_sequential_consumers_and_infer_suspension() {
let source = "add(value: Int, state: Int) (next: Int) pure { next = value + state }\n main() (answer: Int) { answer = reduce(add, stream([1, 2]), 0) }";
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(main.signature.effects.suspend);
assert!(main.signature.effects.async_stream);
for source in [
"main() (values: AsyncStream<Int>) { values = stream([]) }",
"main() (values: AsyncStream<Int>) { values = dever.task.ticks(1) }",
"main(values: AsyncStream<Int>) () { close(values) }",
] {
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(!main.signature.effects.suspend);
}
}
#[test]
fn async_streams_reject_wrong_handlers_nontransferable_values_and_private_intrinsics() {
for (source, code) in [
(
"main(values: List<Stream<Int>>) () { close(stream(values)) }",
"C013",
),
(
"main(values: AsyncStream<Stream<Int>>) () { close(values) }",
"C013",
),
("main() () pure { close(stream([1])) }", "C013"),
("main() () { close(dever.system.taskTicks(1)) }", "C006"),
(
"copy(value: Int) (next: Int) { next = value }\nmain() () { each(copy, stream([1])) }",
"C005",
),
(
"consume(value: Int, state: Stream<Int>) () { close(state) }\nmain(state: Stream<Int>) () { each(consume, stream([1]), state) }",
"C013",
),
("main() () { close(stream(1)) }", "C005"),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn streams_preserve_existing_error_obligations() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles stream errors\") {}\n";
assert!(
linked(&format!(
"{prefix}main() () {{ each(handle, stream([Result.Failed(\"reason\")])) }}"
))
.is_ok()
);
for body in [
"values = stream([Result.Failed(\"reason\")])\n close(values)",
"values = stream([Result.Failed(\"reason\")])",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ {body} }}"))
.unwrap_err()
.code,
"C012"
);
}
assert!(linked(&format!("{prefix}main() (values: AsyncStream<Result>) {{ values = stream([Result.Failed(\"reason\")]) }}")).is_ok());
}
#[test]
fn closed_stream_aliases_cannot_discharge_unread_error_values() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles errors\") {}\nforward(values: AsyncStream<Result>) (output: AsyncStream<Result>) { output = values }\ntype Box { values: AsyncStream<Result> }\n";
for body in [
"values = stream([Result.Failed(\"reason\")])\n alias = values\n close(alias)\n each(handle, values)",
"values = stream([Result.Failed(\"reason\")])\n alias = forward(values)\n close(alias)\n each(handle, values)",
"values = stream([Result.Failed(\"reason\")])\n box = Box { values = values }\n close(box.values)\n each(handle, values)",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ {body} }}"))
.unwrap_err()
.code,
"C012",
"{body}"
);
}
assert_eq!(linked(&format!("{prefix}main() (output: AsyncStream<Result>) {{ values = stream([Result.Failed(\"reason\")])\n close(values)\n output = values }}")).unwrap_err().code, "C012");
assert!(linked(&format!("{prefix}main() () {{ original = [Result.Failed(\"reason\")]\n close(stream(original))\n each(handle, original) }}")).is_ok());
assert!(linked(&format!("{prefix}main() () {{ original = [Result.Failed(\"reason\")]\n first = stream(original)\n second = stream(original)\n close(first)\n each(handle, second) }}")).is_ok());
}
#[test]
fn forwarding_a_lazy_stream_keeps_future_item_error_obligations() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nforward(values: AsyncStream<Result>) (output: AsyncStream<Result>) { output = values }\ncollect(value: Result, state: List<Result>) (next: List<Result>) { next = append(state, value) }\n";
for input in [
"stream(values)",
"forward(stream(values))",
"forward(forward(stream(values)))",
] {
assert_eq!(
linked(&format!(
"{prefix}main(values: Channel<Result>) () {{ lost = reduce(collect, {input}, []) }}"
))
.unwrap_err()
.code,
"C012",
"{input}"
);
assert!(linked(&format!("{prefix}main(values: Channel<Result>) (output: List<Result>) {{ output = reduce(collect, {input}, []) }}")).is_ok());
}
assert!(
linked(&format!(
"{prefix}main(values: Channel<Result>) () {{ close(forward(stream(values))) }}"
))
.is_ok()
);
}
+84
View File
@@ -0,0 +1,84 @@
use super::linked;
use dever::compiler::build;
#[test]
fn channel_types_require_transferable_elements_and_typed_values() {
assert!(linked("main() () { values = channel(List<Int?>, 2)\n send(values, [null, 1])\n close(values) }").is_ok());
assert!(linked("main() () { values = channel(Channel<Int>, 1)\n send(values, channel(Int, 1))\n close(values) }").is_ok());
assert!(linked("type Message { Value(value: Int?) }\nmain() () { values = channel(Message, 1)\n send(values, Message.Value(null))\n close(values) }").is_ok());
for (source, code) in [
("main() () { values = channel(Int, true) }", "C005"),
("main() () { values = channel(Int?, 1) }", "C013"),
("main(values: Channel<Int?>) () { close(values) }", "C013"),
(
"type Unused { values: Channel<Int?> }\nmain() () {}",
"C013",
),
(
"main() () { values = channel(Int, 1)\n send(values, true) }",
"C005",
),
("main() () { values = channel(Stream<Int>, 1) }", "C013"),
(
"main() () { values = channel(List<Stream<Int>>, 1) }",
"C013",
),
(
"main() () { values = channel(Map<Channel<Int>, Int>, 1) }",
"C005",
),
("type Loop { next: Channel<Loop> }\nmain() () {}", "C007"),
("main() () pure { values = channel(Int, 1) }", "C013"),
(
"main(values: Stream<Int>, channel: Channel<Int>) () { close(channel)\n close(values) }",
"C013",
),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn send_transfers_errors_and_receive_requires_handling_them() {
let prefix = "type Result { Done\n error Failed(message: Text) }\n";
assert!(linked(&format!("{prefix}main() () {{ values = channel(Result, 1)\n send(values, Result.Failed(\"reason\"))\n close(values) }}")).is_ok());
assert_eq!(
linked(&format!(
"{prefix}main(values: Channel<Result>) () {{ lost = receive(values)\n close(values) }}"
))
.unwrap_err()
.code,
"C012"
);
assert!(
linked(&format!(
"{prefix}main(values: Channel<Result>) (answer: Result?) {{ answer = receive(values) }}"
))
.is_ok()
);
}
#[test]
fn channel_creation_and_type_references_enable_dependency_without_suspending() {
for (source, required) in [
(
"main() (value: Channel<Int>) { value = channel(Int, 1) }",
true,
),
("main() (value: Channel<Int>?) { value = null }", true),
("type Unused { value: Channel<Int> }\nmain() () {}", false),
] {
let program = linked(source).unwrap();
let main = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
assert!(!program.functions[main].signature.effects.suspend);
assert!(!program.functions[main].signature.effects.schedule);
assert_eq!(
build::runtime_features(&program, &program.reachable([main])).contains(&"task"),
required
);
}
}
+77
View File
@@ -0,0 +1,77 @@
use super::linked;
#[test]
fn groups_require_local_affine_handles_and_zero_output_tasks() {
let prefix = "work() () {}\nvalue() (answer: Int) { answer = 1 }\n";
for body in [
"pool = group(2)\n run(pool, work())\n run(pool, work())\n wait(pool)",
"wait(group(1))\n stop(group(2))",
"pool = group(1)\n wait(pool)\n pool = group(2)\n stop(pool)",
] {
assert!(
linked(&format!("{prefix}main() () {{ {body} }}")).is_ok(),
"{body}"
);
}
for body in [
"pool = group(2)",
"pool = group(2)\n alias = pool\n wait(pool)",
"pool = group(2)\n values = [pool]\n wait(pool)",
"pool = group(2)\n wait(pool)\n wait(pool)",
"pool = group(2)\n wait(pool)\n run(pool, work())",
"pool = group(2)\n pool = group(1)\n wait(pool)",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ {body} }}"))
.unwrap_err()
.code,
"C013",
"{body}"
);
}
for body in [
"run(group(1), work())",
"pool = group(1)\n run(pool, value())\n wait(pool)",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ {body} }}"))
.unwrap_err()
.code,
"C005"
);
}
}
#[test]
fn group_wait_discharges_inputs_but_stop_does_not() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles input\") {}\n";
let body = "pool = group(2)\n run(pool, handle(Result.Failed(\"first\")))\n run(pool, handle(Result.Failed(\"second\")))";
assert!(linked(&format!("{prefix}main() () {{ {body}\n wait(pool) }}")).is_ok());
assert_eq!(
linked(&format!("{prefix}main() () {{ {body}\n stop(pool) }}"))
.unwrap_err()
.code,
"C012"
);
}
#[test]
fn group_arguments_keep_ownership_and_transferability_rules() {
let prefix =
"work(value: Int) () {}\nduration() (value: Int) { dever.task.sleep(0)\n value = 1 }\n";
assert!(
linked(&format!(
"{prefix}main() () {{ pool = group(1)\n run(pool, work(duration()))\n wait(pool) }}"
))
.is_ok()
);
assert_eq!(
linked(&format!(
"{prefix}main() () {{ pool = group(1)\n run(pool, work(length([wait(pool)]))) }}"
))
.unwrap_err()
.code,
"C013"
);
assert_eq!(linked("work(values: List<Stream<Int>>) () {}\nmain() () { pool = group(1)\n run(pool, work([]))\n wait(pool) }").unwrap_err().code, "C013");
}
+190
View File
@@ -0,0 +1,190 @@
use dever::compiler::{check, model::Program};
use dever::syntax::{diagnostic::Diagnostic, source::Source};
mod async_stream;
mod channel;
mod group;
mod parallel;
mod select;
fn linked(text: &str) -> Result<Program, Diagnostic> {
let inputs = [
("main", text),
("dever.io", include_str!("../../../../lib/dever/io.dever")),
(
"dever.time",
include_str!("../../../../lib/dever/time.dever"),
),
(
"dever.task",
include_str!("../../../../lib/dever/task.dever"),
),
];
let modules = inputs
.iter()
.enumerate()
.map(|(index, (package, text))| {
dever::syntax::parse(&Source::new(
index,
if index == 0 {
"main.dever".into()
} else {
format!("{package}.dever")
},
(*text).into(),
))
})
.collect::<Result<Vec<_>, _>>()?;
check::check(
modules,
&inputs
.iter()
.map(|(package, _)| (*package).into())
.collect::<Vec<_>>(),
)
}
#[test]
fn task_ownership_rejects_aliases_discard_overwrite_and_double_consumption() {
let prefix = "work() (value: Int) { value = 42 }\n";
for body in [
"task = run(work())",
"run(work())",
"task = run(work())\n alias = task\n value = wait(task)",
"task = run(work())\n tasks = [task]\n value = wait(task)",
"task = run(work())\n task = run(work())\n value = wait(task)",
"task = run(work())\n value = wait(task)\n again = wait(task)",
] {
let source = format!("{prefix}main() () {{ {body} }}");
let error = linked(&source).unwrap_err();
assert!(matches!(error.code, "C005" | "C013"), "{source}: {error:?}");
}
assert!(
linked(&format!(
"{prefix}main() (answer: Int) {{ task = run(work())\n answer = wait(task) }}"
))
.is_ok()
);
assert!(
linked(&format!(
"{prefix}main() (answer: Int) {{ answer = wait(run(work())) }}"
))
.is_ok()
);
assert_eq!(
linked(&format!("{prefix}main() () {{ stop(run(work())) }}"))
.unwrap_err()
.code,
"C005"
);
}
#[test]
fn short_circuit_cannot_conditionally_consume_tasks() {
let prefix = "work() (value: Bool) { value = true }\n";
assert_eq!(linked(&format!("{prefix}main(flag: Bool) (answer: Bool) {{ task = run(work())\n answer = flag and wait(task) }}")).unwrap_err().code, "C013");
assert!(linked(&format!("{prefix}main() (answer: Bool) {{ task = run(work())\n unused = false and wait(task)\n answer = wait(task) }}")).is_ok());
assert!(
linked(&format!(
"{prefix}main() (answer: Bool) {{ task = run(work())\n answer = true and wait(task) }}"
))
.is_ok()
);
}
#[test]
fn effects_distinguish_cpu_workers_blocking_calls_and_async_calls() {
for source in [
"main() () { dever.task.sleep(0)\n dever.io.println(\"bad\") }",
"main() () { dever.task.sleep(0)\n dever.time.sleep(0) }",
"work() () {}\nmain() () { parallel(work()) }",
"work() () pure {}\nmain() () { blocking(work()) }",
"main() () { blocking(dever.task.sleep(0)) }",
"main() () pure { stop(run(dever.task.sleep(0))) }",
"main() () pure { dever.time.sleep(0) }",
] {
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
}
let program = linked("calc() (value: Int) pure { value = 42 }\nmain() (answer: Int) { answer = parallel(calc())\n blocking(dever.time.sleep(0))\n stop(run(dever.task.sleep(0))) }").unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(main.signature.effects.suspend);
let calc = program
.functions
.iter()
.find(|function| function.signature.name == "calc")
.unwrap();
assert!(!calc.signature.effects.suspend);
}
#[test]
fn nontransferable_values_cannot_escape_into_tasks_or_cross_suspension() {
for source in [
"work(stream: Stream<Int>) () { close(stream) }\nmain(stream: Stream<Int>) () { stop(run(work(stream))) }",
"type Box { stream: Stream<Int> }\nwork(value: Box) () { close(value.stream) }\nmain(value: Box) () { stop(run(work(value))) }",
"main(stream: Stream<Int>) () { dever.task.sleep(0)\n close(stream) }",
"type Box { stream: Stream<Int> }\nmain(value: Box) () { dever.task.sleep(0)\n close(value.stream) }",
"work(stream: Stream<Int>) () { close(stream)\n dever.task.sleep(0) }\nmain(streams: List<Stream<Int>>) () { each(work, streams) }",
] {
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
}
assert!(linked("main(stream: Stream<Int>) () { close(stream)\n dever.task.sleep(0) }").is_ok());
assert!(linked("main(file: dever.system.File) () { blocking(dever.io.close(file)) }").is_ok());
}
#[test]
fn task_failure_values_cannot_be_silently_discarded() {
let prefix = "type Result { Done(value: Int)\n error Failed(message: Text) }\nwork() (answer: Result) { answer = Result.Failed(\"reason\") }\n";
assert_eq!(
linked(&format!(
"{prefix}main() () {{ task = run(work())\n lost = wait(task) }}"
))
.unwrap_err()
.code,
"C012"
);
assert!(
linked(&format!(
"{prefix}main() (answer: Result) {{ task = run(work())\n answer = wait(task) }}"
))
.is_ok()
);
assert_eq!(
linked("main() () { lost = result(dever.task.sleep(-1)) }")
.unwrap_err()
.code,
"C012"
);
assert!(
linked("main() (answer: dever.time.SleepResult) { answer = result(dever.task.sleep(-1)) }")
.is_ok()
);
}
#[test]
fn timer_intrinsics_cannot_be_called_by_user_packages() {
for name in ["timeSleep", "taskSleep"] {
assert_eq!(
linked(&format!("main() () {{ value = dever.system.{name}(0) }}"))
.unwrap_err()
.code,
"C006"
);
}
}
#[test]
fn cancelling_a_task_does_not_claim_its_input_errors_were_handled() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"worker handles an optional error\") {}\n";
let cancelled = format!(
"{prefix}main() () {{ pending = run(handle(Result.Failed(\"reason\")))\n stop(pending) }}"
);
assert_eq!(linked(&cancelled).unwrap_err().code, "C012");
let waited = format!(
"{prefix}main() () {{ pending = run(handle(Result.Failed(\"reason\")))\n wait(pending) }}"
);
assert!(linked(&waited).is_ok());
}
+86
View File
@@ -0,0 +1,86 @@
use super::linked;
#[test]
fn parallel_consumers_select_threads_or_runtime_from_their_body() {
let prefix = "work(value: Int) () pure {}\n";
let program = linked(&format!(
"{prefix}main() () {{ parallelEach(work, [1, 2], 2) }}"
))
.unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(!main.signature.effects.suspend);
assert!(main.signature.effects.blocking);
for body in [
"dever.task.sleep(0)\n parallelEach(work, [1, 2], 2)",
"parallelEach(work, stream([1, 2]), 2)",
] {
let program = linked(&format!("{prefix}main() () {{ {body} }}")).unwrap();
let main = program
.functions
.iter()
.find(|function| function.signature.name == "main")
.unwrap();
assert!(main.signature.effects.suspend);
assert!(main.signature.effects.schedule);
assert!(!main.signature.effects.blocking);
}
assert!(linked("work(value: Int) () { dever.task.sleep(value) }\nmain() () { parallelEach(work, [0, 1], 2) }").is_ok());
}
#[test]
fn parallel_handlers_keep_typed_outputs_context_and_transfer_boundaries() {
for (source, code) in [
(
"work(value: Int) (answer: Int) { answer = value }\nmain() () { parallelEach(work, [1], 2) }",
"C005",
),
(
"work(value: Int) () {}\nmain() () { parallelEach(work, [1], true) }",
"C005",
),
(
"work(value: Int, context: Bool) () {}\nmain() () { parallelEach(work, [1], 2, 3) }",
"C005",
),
(
"work(value: Stream<Int>) () { close(value) }\nmain(values: List<Stream<Int>>) () { parallelEach(work, values, 2) }",
"C013",
),
(
"work(value: Int) () pure {}\nmain() () pure { parallelEach(work, [1], 2) }",
"C013",
),
(
"work(value: Int) () {}\nmain(values: Stream<Int>) () { dever.task.sleep(0)\n parallelEach(work, values, 2) }",
"C013",
),
(
"leaf(value: Int) () {}\nwork(value: Int) () { parallelEach(leaf, [value], 2) }\nmain() () { dever.task.sleep(0)\n parallelEach(work, [1], 2) }",
"C013",
),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn parallel_each_transfers_errors_only_through_valid_handlers() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles each error\") {}\n";
assert!(
linked(&format!(
"{prefix}main() () {{ parallelEach(handle, [Result.Failed(\"reason\")], 2) }}"
))
.is_ok()
);
assert!(
linked(&format!(
"{prefix}main() () {{ parallelEach(handle, stream([Result.Failed(\"reason\")]), 2) }}"
))
.is_ok()
);
assert_eq!(linked("type Result { error Failed(values: List<Stream<Int>>) }\nwork(value: Int) () { fail(Result.Failed([])) }\nmain() () { parallelEach(work, [1], 2) }").unwrap_err().code, "C013");
}
+116
View File
@@ -0,0 +1,116 @@
use super::linked;
#[test]
fn selection_checks_named_outputs_static_fallback_and_affine_ownership() {
let prefix = "work() (value: Int) { value = 1 }\nfallback() (value: Int) { value = 2 }\n";
for body in [
"value = timeout(run(work()), 1, fallback)",
"first = run(work())\n second = run(work())\n value = race(first, second)",
] {
assert!(linked(&format!("{prefix}main() (value: Int) {{ {body} }}")).is_ok());
}
for (extra, body, code) in [
(
"wrong() (answer: Int) { answer = 0 }",
"value = timeout(run(work()), 1, wrong)",
"C005",
),
(
"wrong(input: Int) (value: Int) { value = input }",
"value = timeout(run(work()), 1, wrong)",
"C005",
),
(
"wrong() (answer: Int) { answer = 0 }",
"value = race(run(work()), run(wrong()))",
"C005",
),
("", "value = race(run(work()))", "C005"),
("", "value = race(run(work()), 1)", "C005"),
(
"",
"pending = run(work())\n value = race(pending, pending)",
"C013",
),
(
"",
"pending = run(work())\n value = timeout(pending, 1, fallback)\n again = wait(pending)",
"C013",
),
("", "value = timeout(run(work()), 1.0e0, fallback)", "C005"),
] {
let source = format!("{prefix}{extra}\nmain() (value: Int) {{ {body} }}");
assert_eq!(linked(&source).unwrap_err().code, code, "{source}");
}
}
#[test]
fn timeout_retains_task_ownership_while_duration_is_awaited() {
let source = "work() (value: Int) { value = 1 }\nfallback() (value: Int) { value = 2 }\nduration() (value: Int) { dever.task.sleep(0)\n value = 1 }\nmain() (value: Int) { value = timeout(run(work()), duration(), fallback) }";
assert!(linked(source).is_ok());
assert_eq!(linked("work() () {}\nfallback() () { dever.io.println(\"blocked\") }\nmain() () { timeout(run(work()), 1, fallback) }").unwrap_err().code, "C013");
}
#[test]
fn cancellation_does_not_discharge_input_error_obligations() {
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles input\") {}\nempty() () {}\n";
for body in [
"timeout(run(handle(Result.Failed(\"first\"))), 1, empty)",
"race(run(handle(Result.Failed(\"first\"))), run(handle(Result.Failed(\"second\"))))",
"problem = Result.Failed(\"shared\")\n race(run(handle(problem)), run(empty()))",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ {body} }}"))
.unwrap_err()
.code,
"C012"
);
}
assert!(linked(&format!("{prefix}main() () {{ problem = Result.Failed(\"shared\")\n race(run(handle(problem)), run(handle(problem))) }}")).is_ok());
}
#[test]
fn selected_error_values_must_be_returned_or_handled() {
let prefix = "type Result { Done(value: Int)\n error Failed(message: Text) }\nwork() (value: Result) { value = Result.Failed(\"reason\") }\nfallback() (value: Result) { value = Result.Done(0) }\n";
for operation in [
"timeout(run(work()), 1, fallback)",
"race(run(work()), run(work()))",
] {
assert_eq!(
linked(&format!("{prefix}main() () {{ lost = {operation} }}"))
.unwrap_err()
.code,
"C012"
);
assert!(
linked(&format!(
"{prefix}main() (value: Result) {{ value = {operation} }}"
))
.is_ok()
);
}
}
#[test]
fn selected_ranges_include_fallback_and_each_possible_winner() {
let prefix = "one() (value: Int) { value = 1 }\ntwo() (value: Int) { value = 2 }\n";
for operation in [
"timeout(run(one()), 1, two)",
"race(run(one()), run(two()))",
] {
assert_eq!(
linked(&format!(
"{prefix}main() (value: Int <= 1) {{ value = {operation} }}"
))
.unwrap_err()
.code,
"C011"
);
assert!(
linked(&format!(
"{prefix}main() (value: Int <= 2) {{ value = {operation} }}"
))
.is_ok()
);
}
}
+106
View File
@@ -0,0 +1,106 @@
use super::{database::ERROR, package::packages};
#[test]
fn public_entry_checks_actual_storage_instead_of_component_membership() {
let files = [
("dever.database", ERROR),
(
"demo.account.model",
"global type Account { subject: Text }",
),
(
"demo.account.app",
"count() (value: Int) { value = model.count() }",
),
("demo.account.api", "public get count = app.count"),
("demo.note.model", "type Note { title: Text }"),
(
"demo.note.app",
"count() (value: Int) { value = model.count() }\nhealth() (value: Bool) { value = true }",
),
(
"demo.note.api",
"cmd count = app.count\npublic get health = app.health",
),
];
let program = packages(&files).unwrap();
assert!(program.check_tenant_entries().is_ok());
for entry in &program.http {
let access = program.tenant_access(entry.function);
assert!(!access.required);
assert_eq!(access.components.into_iter().collect::<Vec<_>>(), ["demo"]);
}
let access = program.tenant_access(program.commands[0].function);
assert!(access.required);
assert_eq!(access.components.into_iter().collect::<Vec<_>>(), ["demo"]);
let mut public = files;
public[6].1 = "public get count = app.count\npublic get health = app.health";
let error = packages(&public)
.unwrap()
.check_tenant_entries()
.unwrap_err();
assert!(error.contains("tenant storage"), "{error}");
}
#[test]
fn tenant_storage_follows_cross_component_calls() {
let program = packages(&[
("dever.database", ERROR),
("news.item.model", "type Item { title: Text }"),
(
"news.item.app",
"count() (value: Int) { value = model.count() }",
),
("notes.item.model", "type Item { title: Text }"),
(
"notes.item.app",
"count() (value: Int) { value = model.count() }",
),
(
"report.items.app",
"count() (value: Int) { value = news.item.count() + notes.item.count() }",
),
("report.items.api", "public get count = app.count"),
])
.unwrap();
let access = program.tenant_access(program.http[0].function);
assert!(access.required);
assert_eq!(
access.components.into_iter().collect::<Vec<_>>(),
["news", "notes"]
);
let error = program.check_tenant_entries().unwrap_err();
assert!(error.contains("report/items"), "{error}");
}
#[test]
fn model_free_jobs_require_tenants_but_global_only_jobs_do_not() {
for global in [false, true] {
let mut files = vec![
("dever.database", ERROR),
(
"demo.queue.app",
"submit(key: Text) (id: Id) { id = dever.job.enqueue(job.execute, key) }\napply() () {}",
),
(
"demo.queue.job",
"database default\njob execute() () retry(1) timeout(100) { app.apply() }",
),
("demo.queue.api", "cmd submit = app.submit"),
];
if global {
files.push((
"demo.account.model",
"global type Account { subject: Text }",
));
}
let program = packages(&files).unwrap();
let entry = program.commands[0].function;
let access = program.tenant_access(entry);
assert_eq!(access.required, !global);
assert_eq!(
access.components.into_iter().collect::<Vec<_>>(),
if global { vec![] } else { vec!["demo"] }
);
}
}
+116
View File
@@ -0,0 +1,116 @@
use super::package::packages;
const STORAGE: &str = "public type PutResult { Stored(key: Uuid)\nerror Failed(message: Text) }\npublic put(upload: Upload) (key: Uuid) { key = stored(dever.system.uploadPut(upload)) }\nstored(result: PutResult.Stored(value)) (key: Uuid) { key = value }\nstored(result: PutResult.Failed(message)) (key: Uuid) { fail(PutResult.Failed(message)) }";
const APP: &str = "accept(file: Upload, caption: Text) (size: Int, name: Text) { size = dever.api.uploadSize(file)\nname = caption + dever.api.uploadFilename(file)\ndever.api.closeUpload(file) }";
#[test]
fn upload_metadata_then_close_preserves_a_single_owned_post_input() {
let program = packages(&[
("demo.media.app", APP),
("demo.media.api", "public post accept = app.accept"),
])
.unwrap();
assert!(
dever::compiler::build::runtime_features(&program, &program.entries().collect())
.contains(&"upload")
);
for api in ["public get accept = app.accept", "cmd accept = app.accept"] {
assert!(
packages(&[("demo.media.app", APP), ("demo.media.api", api)]).is_err(),
"{api}"
);
}
}
#[test]
fn upload_rejects_duplication_nesting_output_and_helpers() {
for app in [
"accept(file: Upload) () {}",
"accept(file: Upload) () { dever.api.closeUpload(file)\ndever.api.closeUpload(file) }",
"accept(file: Upload) (size: Int) { dever.api.closeUpload(file)\nsize = dever.api.uploadSize(file) }",
"accept(file: Upload) () { copy = file\ndever.api.closeUpload(copy) }",
"accept(file: Upload) (out: Upload) { out = file }",
"type Container { file: Upload }",
"type Container { Wrapped(file: Upload) }",
"accept(files: List<Upload>) () {}",
"accept(file: Upload?) () {}",
"accept(file: Upload, other: Upload) () { dever.api.closeUpload(file)\ndever.api.closeUpload(other) }",
"accept(file: Upload) () { helper(file) }\nhelper(file: Upload) () { dever.api.closeUpload(file) }",
] {
let error = packages(&[("demo.media.app", app)]).unwrap_err();
assert!(
!error.code.starts_with('P'),
"syntax did not reach ownership check: {error:?}"
);
}
assert!(packages(&[("demo.media.domain", "ignore(file: Upload) () {}")]).is_err());
}
#[test]
fn upload_storage_requires_a_same_domain_port_and_never_escapes_to_tasks() {
let app = "accept(file: Upload) (key: Uuid) { key = port.store(file) }";
let port = "store(file: Upload) (key: Uuid) fails dever.storage.PutResult";
let adapter = "port.store(file: Upload) (key: Uuid) { key = dever.storage.put(file) }";
let checked = |app: &str| {
packages(&[
("dever.storage", STORAGE),
("demo.media.app", app),
("demo.media.port", port),
("demo.media.adapter.disk", adapter),
("demo.media.api", "public post accept = app.accept"),
])
};
checked(app).unwrap();
for app in [
"accept(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
"accept(file: Upload) (key: Uuid) { task = run(port.store(file))\nkey = wait(task) }",
"accept(file: Upload) (key: Uuid) { key = blocking(port.store(file)) }",
"accept(file: Upload) (key: Uuid) { key = parallel(port.store(file)) }",
] {
assert!(checked(app).is_err(), "{app}");
}
}
#[test]
fn upload_storage_remains_an_external_effect_for_database_transactions() {
let check = |prefix: &str, api: &str| {
packages(&[
("dever.storage", STORAGE),
("dever.database", super::database::ERROR),
("demo.media.model", "type Entry { name: Text }"),
(
"demo.media.app",
&format!(
"{prefix}accept(file: Upload) (key: Uuid) {{ row = model.create({{ name = \"upload\" }})\nkey = port.store(file) }}"
),
),
(
"demo.media.port",
"store(file: Upload) (key: Uuid) fails dever.storage.PutResult",
),
(
"demo.media.adapter",
"port.store(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
),
("demo.media.api", api),
])
};
assert_eq!(
check("", "public post accept = app.accept")
.unwrap_err()
.code,
"C014"
);
assert_eq!(
check("transaction ", "public post accept = app.accept")
.unwrap_err()
.code,
"C014"
);
assert_eq!(
check("", "public long post accept = app.accept")
.unwrap_err()
.code,
"C014"
);
}
+88
View File
@@ -0,0 +1,88 @@
use super::checked;
use dever::{
compiler::{build, emit},
syntax::source::Source,
};
#[test]
fn emission_keeps_only_called_ownership_versions() {
let text = "decorate(value: Text, loud: true) (answer: Text) { answer = value + \"!\" }\ndecorate(value: Text, loud: false) (answer: Text) { answer = value + \".\" }\nmain(value: Text, loud: Bool) (answer: Text) { answer = decorate(value + \"hello\", loud) }";
let program = checked(text).unwrap();
let source = Source::new(0, "case.dever", text.into());
let main = program
.functions
.iter()
.position(|function| function.signature.name == "main")
.unwrap();
let decorate = program
.functions
.iter()
.position(|function| function.signature.name == "decorate")
.unwrap();
let generated = emit::generate(&program, &[source], &[main].into());
assert!(generated.contains(&format!("fn function{decorate}_own0(")));
assert!(!generated.contains(&format!("fn function{decorate}(")));
}
#[test]
fn final_record_fields_keep_ownership_across_calls() {
let text = "type Packet { path: Text\n values: List<Int> }\nselected(path: \"/echo\", values: List<Int>) (answer: List<Int>) { answer = values }\nselected(path: other, values: List<Int>) (answer: List<Int>) { answer = [] }\nroute(packet: Packet) (answer: List<Int>) { answer = selected(packet.path, packet.values) }\nmain() (answer: List<Int>) { packet = Packet { path = \"/echo\", values = [1, 2, 3] }\n answer = route(packet) }";
let program = checked(text).unwrap();
let source = Source::new(0, "case.dever", text.into());
let index = |name| {
program
.functions
.iter()
.position(|function| function.signature.name == name)
.unwrap()
};
let generated = emit::generate(&program, &[source], &[index("main")].into());
let selected = index("selected");
assert!(generated.contains(&format!("fn function{selected}_own0_own1(")));
assert!(!generated.contains(&format!("fn function{selected}(")));
}
#[test]
fn emitted_types_follow_entry_variables_errors_and_nested_fields() {
let text = "type Leaf { value: Channel<Int>? }\ntype Box { leaves: List<Leaf> }\ntype Unused { value: AsyncStream<Int> }\nmake() (value: Box) { value = Box { leaves = [] } }\nmain() () { value = make() }\nplain() (answer: Int) { answer = 42 }";
let program = checked(text).unwrap();
let source = Source::new(0, "case.dever", text.into());
for (entry, required) in [("main", true), ("plain", false)] {
let index = program
.functions
.iter()
.position(|function| function.signature.name == entry)
.unwrap();
let functions = program.reachable([index]);
assert_eq!(
build::runtime_features(&program, &functions).contains(&"task"),
required
);
let generated = emit::generate(&program, std::slice::from_ref(&source), &functions);
for (index, definition) in program.definitions.iter().enumerate() {
let wanted = required && matches!(definition.name.as_str(), "Leaf" | "Box");
assert_eq!(
generated.contains(&format!("pub struct Type{index} {{")),
wanted,
"{entry}: {}",
definition.name
);
}
}
}
#[test]
fn unused_types_are_still_validated_before_emission() {
assert_eq!(
checked("type Unused { values: Channel<Stream<Int>> }\nmain() () {}")
.unwrap_err()
.code,
"C013"
);
assert_eq!(
checked("type Unused { next: Unused? }\nmain() () {}")
.unwrap_err()
.code,
"C007"
);
}
+37
View File
@@ -0,0 +1,37 @@
use dever::{
compiler::{check, model::Program},
syntax::{diagnostic::Diagnostic, source::Source},
};
fn linked(text: &str) -> Result<Program, Diagnostic> {
let main = dever::syntax::parse(&Source::new(0, "main.dever", text.into()))?;
let library = dever::syntax::parse(&Source::new(
1,
"uuid.dever",
include_str!("../../../lib/uuid.dever").into(),
))?;
check::check(vec![main, library], &["main".into(), "uuid".into()])
}
#[test]
fn uuids_are_distinct_values_with_typed_nullable_parsing() {
assert!(linked("main(value: Uuid) (answer: Int?) pure { values = { value = 42 }\n answer = get(values, value) }").is_ok());
for (source, code) in [
(
"main() (value: Uuid) { value = \"550e8400-e29b-41d4-a716-446655440000\" }",
"C005",
),
(
"main(value: Uuid, other: Id) (answer: Bool) { answer = value == other }",
"C005",
),
(
"main(value: Uuid) (answer: Bool) { answer = value < value }",
"C005",
),
("main() () { value = dever.system.uuidParse(\"\") }", "C006"),
("main() (value: Uuid) { value = uuid.parse(\"\") }", "C005"),
] {
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 真实签名测试包通过 production prepare 编译与执行,并验证源码移除后的行为。
use super::*;
use crate::compiler::{build, project::Project};
use std::{fs, path::Path, process::Command};
#[path = "build_fixture.rs"]
mod fixture;
struct EmptyStore;
impl Store for EmptyStore {
fn get(&self, _: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
Ok(None)
}
fn put(&self, _: &[u8]) -> Result<(), String> {
panic!("Worker prepare must not write cache")
}
}
fn project(root: &Path) {
for (path, source) in [
("app", "value() (value: Int) { value = port.native() }\n"),
("port", "native() (value: Int) fails dever.api.Error\n"),
("adapter", "external go \"worker/main.go\" {}\n"),
("api", "cmd value = app.value\n"),
] {
fixture::write(root, &format!("module/demo/value/{path}.dever"), source);
}
fixture::write(
root,
"module/demo/value/worker/main.go",
"package main\nimport (\"context\"; _ \"embed\"; \"dever-worker/helper\")\n//go:embed note.txt\nvar note string\nfunc Native(_ context.Context, _ any, _ any) (any, error) { return map[string]any{\"value\":helper.Value()+Target()+int64(len(note))}, nil }\n",
);
fixture::write(
root,
"module/demo/value/worker/helper/value.go",
"package helper\nimport \"dever-worker/helper/internal/value\"\nfunc Value() int64 { return value.Get() }\n",
);
fixture::write(
root,
"module/demo/value/worker/helper/internal/value/value.go",
"package value\nfunc Get() int64 { return 40 }\n",
);
fixture::write(
root,
"module/demo/value/worker/target_linux.go",
"//go:build linux\npackage main\nfunc Target() int64 { return 1 }\n",
);
fixture::write(
root,
"module/demo/value/worker/target_windows.go",
"//go:build windows\npackage main\nfunc Target() int64 { return 99 }\n",
);
fixture::write(root, "module/demo/value/worker/note.txt", "x");
}
#[test]
#[ignore = "explicit dev-Pod Go authoring fixture; no product tool fallback"]
fn signed_go_pack_compiles_reproducibly_and_runs_native_after_source_removal() {
let fixture = fixture::Fixture::new();
let root = fixture.root.join("project");
project(&root);
let project = Project::load(&root).unwrap();
let program = project.check().unwrap();
let external = program.external_workers().next().unwrap();
let target = BuildTarget::host().unwrap();
let metadata = library::runtime::metadata(&fixture.signed, "go", target).unwrap();
let mut worker = Worker::from_external(external).unwrap();
worker.runtime = Some(metadata.pack.clone());
let mut lock = Lock::new(vec![]);
lock.workers.push(worker);
fixture::write(&root, "dever.lock", lock.encode().unwrap());
let prepare = || {
crate::compiler::project::dependency::worker::prepare_with_store(
&root,
&program,
target,
&fixture.signed,
&EmptyStore,
)
};
let files = prepare().unwrap();
let repeated = prepare().unwrap();
assert_eq!(
files
.iter()
.map(|file| (&file.path, &file.bytes, file.executable))
.collect::<Vec<_>>(),
repeated
.iter()
.map(|file| (&file.path, &file.bytes, file.executable))
.collect::<Vec<_>>()
);
assert!(!files.iter().any(|file| file.path.contains("/source/")
|| file.path.contains("/stdlib/")
|| file.path.ends_with("component.go")));
let binary = build::build_with_resources(
&program,
&project.sources,
"main",
&root.join("app"),
target,
&files,
)
.unwrap();
// 工具与 SDK 篡改、闭包缺失和目标被排除都必须在交付 Worker 前失败。
let installation = fixture.root.join("installation");
let release = installation.join("versions/0.0.1");
let signature = release.join("manifest.sig");
let original = fs::read(&signature).unwrap();
fs::write(&signature, "00".repeat(64)).unwrap();
assert!(SignedResources::load(&installation, "0.0.1").is_err());
fs::write(&signature, original).unwrap();
for relative in [
"sdk/go/component.go".into(),
format!("runtime/go/{}/runtime.pack", target.name()),
format!("runtime/go/{}/manifest.json", target.name()),
] {
let path = release.join(relative);
let original = fs::read(&path).unwrap();
let mut changed = original.clone();
let middle = changed.len() / 2;
changed[middle] ^= 1;
fs::write(&path, changed).unwrap();
assert!(
prepare()
.err()
.expect("tampered signed asset was accepted")
.contains("signed resource bytes differ")
);
fs::write(&path, original).unwrap();
let saved = fixture.root.join("missing-asset");
fs::rename(&path, &saved).unwrap();
assert!(prepare().is_err(), "missing asset used a host fallback");
fs::rename(saved, path).unwrap();
}
let main = root.join("module/demo/value/worker/main.go");
let source = fs::read_to_string(&main).unwrap();
fs::write(
&main,
source.replace("dever-worker/helper", "example.com/unlocked"),
)
.unwrap();
assert!(
prepare()
.err()
.expect("unlocked import was accepted")
.contains("locked module graph")
);
fs::write(&main, format!("//go:build windows\n{source}")).unwrap();
assert!(
prepare()
.err()
.expect("excluded entry was accepted")
.contains("excluded by target")
);
fs::write(&main, &source).unwrap();
for import in ["internal/abi", "dever-worker/helper/internal/value"] {
fs::write(
&main,
source.replace("_ \"embed\"", &format!("_ \"embed\"; _ {import:?}")),
)
.unwrap();
assert!(
prepare()
.err()
.expect("private import was accepted")
.contains("Go internal import")
);
}
fs::write(&main, &source).unwrap();
fixture::write(
&root,
"module/demo/value/worker/cgo.go",
"package main\nimport \"C\"\n",
);
assert!(prepare().is_err());
fs::remove_file(root.join("module/demo/value/worker/cgo.go")).unwrap();
let descriptor: Descriptor = serde_json::from_value(fixture.descriptor.clone()).unwrap();
let mut missing = fixture.files.clone();
missing.remove("stdlib/runtime.a");
assert!(descriptor.validate(&missing, target).is_err());
let mut missing = fixture.files.clone();
missing.remove("bin/compile");
assert!(descriptor.validate(&missing, target).is_err());
let mut version = descriptor.clone();
let actual = version.go_version.split_whitespace().nth(2).unwrap();
version.go_version = version.go_version.replace(actual, "go0.0.0");
assert!(
version
.validate(&fixture.files, target)
.unwrap_err()
.contains("archive target/compiler version differs")
);
let mut wrong = fixture.descriptor.clone();
wrong["goarch"] = "unsupported".into();
assert!(
serde_json::from_value::<Descriptor>(wrong)
.unwrap()
.validate(&fixture.files, target)
.is_err()
);
fs::remove_dir_all(root.join("module")).unwrap();
fs::remove_dir_all(fixture.root.join("installation")).unwrap();
for _ in 0..2 {
let output = Command::new(&binary)
.env_clear()
.args(["demo.value.value", "{}"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.stdout,
b"{\"code\":0,\"message\":\"ok\",\"data\":42}\n"
);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 只在测试自己的目录中,用 dev Pod 已安装的绝对路径 Go 工具制作签名 fixture。
use super::*;
use ring::signature::{Ed25519KeyPair, KeyPair};
use std::{
fs,
os::unix::fs::{DirBuilderExt, PermissionsExt},
path::{Path, PathBuf},
process::Command,
};
pub struct Fixture {
pub root: PathBuf,
pub signed: SignedResources,
pub files: BTreeMap<String, Vec<u8>>,
pub descriptor: serde_json::Value,
}
pub fn write(root: &Path, path: &str, bytes: impl AsRef<[u8]>) {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
for directory in path
.parent()
.unwrap()
.ancestors()
.take_while(|path| path.starts_with(root))
{
fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap();
}
fs::write(&path, bytes).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn go(root: &Path, arguments: &[&str]) -> String {
let result = Command::new("/usr/local/go/bin/go")
.args(arguments)
.current_dir(root)
.env_clear()
.env("GOTOOLCHAIN", "local")
.env("GOPROXY", "off")
.env("GOSUMDB", "off")
.env("CGO_ENABLED", "0")
.env("GO111MODULE", "off")
.env("GOCACHE", root.join("go-cache"))
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap()
}
impl Fixture {
pub fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"dever-go-acceptance-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
let version = go(&root, &["version"]);
let target = BuildTarget::host().unwrap();
let arch = if target == BuildTarget::LinuxX86_64 {
"amd64"
} else {
"arm64"
};
let version_number = version
.split_whitespace()
.nth(2)
.unwrap()
.strip_prefix("go")
.unwrap();
write(&root, "selector.go", include_bytes!("selector.go"));
go(
&root,
&["build", "-trimpath", "-o", "selector", "selector.go"],
);
let mut files = BTreeMap::new();
files.insert(
"bin/analyze".into(),
fs::read(root.join("selector")).unwrap(),
);
for tool in ["compile", "link"] {
files.insert(
format!("bin/{tool}"),
fs::read(format!("/usr/local/go/pkg/tool/linux_{arch}/{tool}")).unwrap(),
);
}
let exports = go(
&root,
&[
"list",
"-deps",
"-export",
"-f",
"{{if .Export}}{{.ImportPath}}={{.Export}}{{end}}",
"bytes",
"context",
"encoding/binary",
"encoding/json",
"errors",
"fmt",
"io",
"math",
"os",
"regexp",
"sort",
"strconv",
"strings",
"time",
"unicode/utf8",
"embed",
],
);
let mut cfg = String::new();
for line in exports.lines().filter(|line| !line.is_empty()) {
let (name, source) = line.split_once('=').unwrap();
let path = format!("stdlib/{name}.a");
cfg.push_str(&format!("packagefile {name}={path}\n"));
files.insert(path, fs::read(source).unwrap());
}
files.insert("stdlib/importcfg".into(), cfg.into_bytes());
let descriptor = serde_json::json!({"host":target.name(), "compiler":"bin/compile", "linker":"bin/link", "analyzer":"bin/analyze",
"stdlib_importcfg":"stdlib/importcfg", "goos":"linux", "goarch":arch, "go_version":version.trim()});
files.insert("dever-runtime.json".into(), serde_json::to_vec(&serde_json::json!({"format":"dever-worker-runtime-v1", "ecosystem":"go", "target":target.name(),
"executable":null, "arguments":[], "build":descriptor, "python_markers":null, "python_wheel_tags":[]})).unwrap());
let archive = archive(&files);
let metadata = serde_json::to_vec(&serde_json::json!({"format":"dever-registry-runtime-v1", "runtime":{"pack":{"name":"go", "version":version_number,
"sha256":crate::compiler::project::dependency::digest(&archive)}, "target":target.name(), "python_markers":null, "python_wheel_tags":[]}})).unwrap();
let loader = if arch == "amd64" {
"ld-linux-x86-64.so.2"
} else {
"ld-linux-aarch64.so.1"
};
let loader_source = if arch == "amd64" {
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"
} else {
"/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
};
let artifacts = BTreeMap::from([
(
format!("runtime/go/{}/runtime.pack", target.name()),
archive,
),
(
format!("runtime/go/{}/manifest.json", target.name()),
metadata,
),
(
format!("process-runtime/{}/lib/{loader}", target.name()),
fs::read(loader_source).unwrap(),
),
(
"sdk/go/component.go".into(),
fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../sdk/go/component.go"))
.unwrap(),
),
]);
let signed = sign(&root.join("installation"), &artifacts);
Self {
root,
signed,
files,
descriptor,
}
}
}
pub fn archive(files: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut tar = tar::Builder::new(gzip);
for (name, bytes) in files {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, name, bytes.as_slice())
.unwrap();
}
tar.into_inner().unwrap().finish().unwrap()
}
pub fn sign(root: &Path, artifacts: &BTreeMap<String, Vec<u8>>) -> SignedResources {
let key = Ed25519KeyPair::from_seed_unchecked(&[93; 32]).unwrap();
let hex = |bytes: &[u8]| {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
};
write(
root,
"state/trusted-release-key",
hex(key.public_key().as_ref()),
);
let entries = artifacts.iter().map(|(path, bytes)| {
write(root, &format!("versions/0.0.1/{path}"), bytes);
serde_json::json!({"path":path, "bytes":bytes.len(), "sha256":crate::compiler::project::dependency::digest(bytes)})
}).collect::<Vec<_>>();
let manifest = serde_json::to_vec(&serde_json::json!({"format":"dever-release-v2", "version":"0.0.1", "platform":BuildTarget::host().unwrap().name(), "artifacts":entries, "extensions":[]})).unwrap();
write(root, "versions/0.0.1/manifest.json", &manifest);
write(
root,
"versions/0.0.1/manifest.sig",
hex(key.sign(&manifest).as_ref()),
);
SignedResources::load(root, "0.0.1").unwrap()
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}
+189
View File
@@ -0,0 +1,189 @@
//! 测试 key 只用于显式注入的 sumdb 对端,不进入产品公钥或项目设置。
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD};
use ring::signature::{Ed25519KeyPair, KeyPair};
use sha2::{Digest, Sha256};
use std::{
cell::RefCell,
io::{Cursor, Write},
};
#[derive(Default)]
pub(super) struct Cache(pub RefCell<BTreeMap<String, Vec<u8>>>);
impl Store for Cache {
fn get(&self, hash: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
Ok(self.0.borrow().get(hash).cloned())
}
fn put(&self, bytes: &[u8]) -> Result<(), String> {
self.0.borrow_mut().insert(
crate::compiler::project::dependency::digest(bytes),
bytes.to_vec(),
);
Ok(())
}
}
#[derive(Clone, Default)]
pub(super) struct Registry(pub BTreeMap<String, Vec<u8>>);
impl Transport for Registry {
fn get(&self, url: &str, limit: usize) -> Result<Vec<u8>, String> {
let bytes = self
.0
.get(url)
.ok_or_else(|| format!("missing owned registry response {url}"))?;
assert!(bytes.len() <= limit);
Ok(bytes.clone())
}
}
pub(super) fn runtime() -> Metadata {
serde_json::from_value(serde_json::json!({"pack":{"name":"go", "version":"1.25.0", "sha256":"0".repeat(64)},
"target":BuildTarget::host().unwrap().name(), "python_markers":null, "python_wheel_tags":[]})).unwrap()
}
pub(super) fn zip(module: &str, version: &str, contents: &str) -> Vec<u8> {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
format!("{module}@v{version}/value.go"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
archive.write_all(contents.as_bytes()).unwrap();
archive.finish().unwrap().into_inner()
}
fn key() -> Ed25519KeyPair {
Ed25519KeyPair::from_seed_unchecked(&[47; 32]).unwrap()
}
fn key_id() -> Vec<u8> {
let mut hash = Sha256::new();
hash.update(b"dever-sumdb-test\n");
hash.update([1]);
hash.update(key().public_key().as_ref());
hash.finalize()[..4].to_vec()
}
pub(super) fn verifier() -> Verifier {
let mut bytes = vec![1];
bytes.extend_from_slice(key().public_key().as_ref());
Verifier::new(&format!(
"dever-sumdb-test+{}+{}",
key_id()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
STANDARD.encode(bytes)
))
.unwrap()
}
fn root(hashes: &[[u8; 32]]) -> [u8; 32] {
if hashes.is_empty() {
return Sha256::digest([]).into();
}
if hashes.len() == 1 {
return hashes[0];
}
let split = hashes.len().next_power_of_two() / 2;
let mut hash = Sha256::new();
hash.update([1]);
hash.update(root(&hashes[..split]));
hash.update(root(&hashes[split..]));
hash.finalize().into()
}
impl Registry {
pub fn module(&mut self, name: &str, version: &str, source: &str) {
let prefix = format!(
"https://proxy.golang.org/{}/@v/v{version}",
module::escape(name)
);
self.0
.insert(format!("{prefix}.mod"), source.as_bytes().to_vec());
self.0.insert(
format!("{prefix}.zip"),
zip(name, version, "package value\n"),
);
}
pub fn seal(&mut self) {
self.0
.retain(|path, _| !path.starts_with("https://sum.golang.org/"));
let records = self
.0
.iter()
.filter_map(|(path, source)| {
let path = path.strip_prefix("https://proxy.golang.org/")?;
let (name, version) = path.split_once("/@v/v")?;
let version = version.strip_suffix(".mod")?;
let archive =
&self.0[&format!("https://proxy.golang.org/{name}/@v/v{version}.zip")];
let text = format!(
"{name} v{version} {}\n{name} v{version}/go.mod {}\n",
sumdb::zip_hash(name, version, archive).unwrap(),
sumdb::mod_hash(source)
);
Some((
format!("https://sum.golang.org/lookup/{name}@v{version}"),
text,
))
})
.collect::<Vec<_>>();
let hashes = records
.iter()
.map(|(_, text)| {
let mut hash = Sha256::new();
hash.update([0]);
hash.update(text);
hash.finalize().into()
})
.collect::<Vec<[u8; 32]>>();
let text = format!(
"go.sum database tree\n{}\n{}\n",
hashes.len(),
STANDARD.encode(root(&hashes))
);
let mut signature = key_id();
signature.extend_from_slice(key().sign(text.as_bytes()).as_ref());
let note = format!(
"{text}\n— dever-sumdb-test {}\n",
STANDARD.encode(signature)
);
self.0.insert(
"https://sum.golang.org/latest".into(),
note.as_bytes().to_vec(),
);
for (id, (path, text)) in records.into_iter().enumerate() {
self.0
.insert(path, format!("{id}\n{text}\n{note}").into_bytes());
}
assert!(hashes.len() < 256);
self.0.insert(
format!("https://sum.golang.org/tile/8/0/000.p/{}", hashes.len()),
hashes.into_iter().flatten().collect(),
);
}
}
pub(super) fn graph() -> Registry {
let mut registry = Registry::default();
for (name, version, source) in [
(
"a",
"1.0.0",
"module example.com/a\nrequire (\nexample.com/b v1.0.0\nexample.com/c v1.0.0\n)\n",
),
("b", "1.0.0", "module example.com/b\n"),
("b", "1.2.0", "module example.com/b\n"),
(
"c",
"1.0.0",
"module example.com/c\nrequire example.com/b v1.2.0\n",
),
] {
registry.module(&format!("example.com/{name}"), version, source);
}
registry.seal();
registry
}
+333
View File
@@ -0,0 +1,333 @@
//! Go 模块协议验收只使用拥有的传输和临时摘要缓存。
use super::*;
mod fixture;
mod proofs;
#[test]
fn independent_worker_graphs_merge_without_erasing_previous_modules() {
let mut registry = fixture::graph();
registry.module(
"example.com/z",
"1.0.0",
"module example.com/z\nrequire example.com/b v1.0.0\n",
);
registry.seal();
let cache = fixture::Cache::default();
let mut lock = Lock::new(vec![]);
for root in ["go:example.com/a@1.0.0", "go:example.com/z@1.0.0"] {
resolve_with(
&[root.parse().unwrap()],
BuildTarget::host().unwrap(),
&fixture::runtime(),
&registry,
&cache,
&mut lock,
&fixture::verifier(),
)
.unwrap();
}
assert_eq!(lock.libs.len(), 5);
assert_eq!(
lock.libs
.iter()
.filter(|lib| lib.spec.name == "example.com/b")
.count(),
2
);
validate_with(&lock, &fixture::verifier()).unwrap();
for (name, version) in [("a", "1.2.0"), ("z", "1.0.0")] {
let closure = selected(
&lock.libs,
&[format!("go:example.com/{name}@1.0.0").parse().unwrap()],
)
.unwrap();
assert_eq!(
closure
.iter()
.find(|lib| lib.spec.name == "example.com/b")
.unwrap()
.spec
.version,
version
);
}
}
#[test]
fn resolves_authenticated_mvs_graph_and_replays_locked_hashes() {
let registry = fixture::graph();
let cache = fixture::Cache::default();
let mut lock = Lock::new(vec![]);
let runtime = fixture::runtime();
resolve_with(
&["go:example.com/a@1.0.0".parse().unwrap()],
BuildTarget::host().unwrap(),
&runtime,
&registry,
&cache,
&mut lock,
&fixture::verifier(),
)
.unwrap();
assert_eq!(lock.libs.len(), 4);
assert_eq!(
selected(&lock.libs, &["go:example.com/a@1.0.0".parse().unwrap()])
.unwrap()
.into_iter()
.find(|lib| lib.spec.name == "example.com/b")
.unwrap()
.spec
.version,
"1.2.0"
);
let records = evidence(&lock).unwrap();
assert!(
records[0]
.records
.iter()
.any(|record| record.module == "example.com/b" && record.version == "1.0.0")
);
let checked = validate_with(&lock, &fixture::verifier()).unwrap();
for lib in &lock.libs {
let artifact = &lib.artifacts[0];
let bytes = cache
.get(&artifact.sha256, artifact.bytes)
.unwrap()
.unwrap();
checked
.verify_zip(&lib.spec.name, &lib.spec.version, &bytes)
.unwrap();
assert!(
checked
.verify_zip(&lib.spec.name, &lib.spec.version, &bytes[..bytes.len() / 2])
.is_err()
);
}
assert!(
validate_lock(&lock).is_err(),
"fixture signing key must never become a product trust root"
);
lock.libs[0].dependencies.clear();
assert!(validate_with(&lock, &fixture::verifier()).is_err());
}
#[test]
fn mvs_keeps_requirements_from_unselected_versions() {
let mut registry = fixture::Registry::default();
for (name, version, requires) in [
(
"a",
"1.0.0",
"require example.com/b v1.0.0\nrequire example.com/c v1.0.0\n",
),
("b", "1.0.0", "require example.com/d v1.2.0\n"),
("b", "1.1.0", "require example.com/d v1.0.0\n"),
(
"c",
"1.0.0",
"require example.com/b v1.1.0\nrequire example.com/d v1.0.0\n",
),
("d", "1.0.0", ""),
("d", "1.2.0", ""),
] {
registry.module(
&format!("example.com/{name}"),
version,
&format!("module example.com/{name}\ngo 1.16\n{requires}"),
);
}
registry.seal();
let roots = ["go:example.com/a@1.0.0".parse().unwrap()];
let mut lock = Lock::new(vec![]);
resolve_with(
&roots,
BuildTarget::host().unwrap(),
&fixture::runtime(),
&registry,
&fixture::Cache::default(),
&mut lock,
&fixture::verifier(),
)
.unwrap();
assert_eq!(lock.libs.len(), 6);
let versions = selected(&lock.libs, &roots)
.unwrap()
.into_iter()
.map(|lib| (lib.spec.name.as_str(), lib.spec.version.as_str()))
.collect::<BTreeMap<_, _>>();
assert_eq!(versions["example.com/b"], "1.1.0");
assert_eq!(versions["example.com/d"], "1.2.0");
let older = lock
.libs
.iter_mut()
.find(|lib| lib.spec.name == "example.com/b" && lib.spec.version == "1.0.0")
.unwrap();
older.dependencies[0].spec.version = "1.0.0".into();
assert!(validate_with(&lock, &fixture::verifier()).is_err());
}
#[test]
fn shared_module_keeps_distinct_worker_selections() {
let mut registry = fixture::Registry::default();
for (name, version, requires) in [
("x", "1.0.0", "require example.com/d v1.0.0\n"),
("y", "1.0.0", "require example.com/d v1.1.0\n"),
("d", "1.0.0", ""),
("d", "1.1.0", ""),
] {
registry.module(
&format!("example.com/{name}"),
version,
&format!("module example.com/{name}\n{requires}"),
);
}
registry.seal();
let first = vec!["go:example.com/x@1.0.0".parse().unwrap()];
let second = vec![
"go:example.com/x@1.0.0".parse().unwrap(),
"go:example.com/y@1.0.0".parse().unwrap(),
];
let mut lock = Lock::new(vec![]);
for roots in [&first, &second] {
resolve_with(
roots,
BuildTarget::host().unwrap(),
&fixture::runtime(),
&registry,
&fixture::Cache::default(),
&mut lock,
&fixture::verifier(),
)
.unwrap();
}
let restored: Lock = serde_json::from_str(&lock.encode().unwrap()).unwrap();
validate_with(&restored, &fixture::verifier()).unwrap();
assert_eq!(restored.libs.len(), 4);
for (roots, expected) in [(&first, "1.0.0"), (&second, "1.1.0")] {
let closure = selected(&restored.libs, roots).unwrap();
assert_eq!(
closure
.iter()
.filter(|lib| lib.spec.name == "example.com/d")
.count(),
1
);
assert_eq!(
closure
.iter()
.find(|lib| lib.spec.name == "example.com/d")
.unwrap()
.spec
.version,
expected
);
}
}
#[test]
fn rejected_metadata_archive_or_rollback_never_publishes_lock() {
let registry = fixture::graph();
let runtime = fixture::runtime();
for suffix in ["mod", "zip"] {
let mut changed = registry.clone();
changed
.0
.get_mut(&format!(
"https://proxy.golang.org/example.com/b/@v/v1.0.0.{suffix}"
))
.unwrap()
.extend(b"changed");
if suffix == "zip" {
changed.0.insert(
"https://proxy.golang.org/example.com/b/@v/v1.2.0.zip".into(),
fixture::zip("example.com/b", "1.2.0", "changed"),
);
}
let cache = fixture::Cache::default();
let mut lock = Lock::new(vec![]);
let before = lock.encode().unwrap();
assert!(
resolve_with(
&["go:example.com/a@1.0.0".parse().unwrap()],
BuildTarget::host().unwrap(),
&runtime,
&changed,
&cache,
&mut lock,
&fixture::verifier()
)
.is_err()
);
assert_eq!(lock.encode().unwrap(), before);
assert!(cache.0.borrow().is_empty());
}
let cache = fixture::Cache::default();
let mut lock = Lock::new(vec![]);
let mut first = fixture::Registry::default();
first.module("example.com/a", "1.0.0", "module example.com/a\n");
first.seal();
let roots = ["go:example.com/a@1.0.0".parse().unwrap()];
resolve_with(
&roots,
BuildTarget::host().unwrap(),
&runtime,
&first,
&cache,
&mut lock,
&fixture::verifier(),
)
.unwrap();
let mut next = first.clone();
next.module("example.com/z", "1.0.0", "module example.com/z\n");
next.seal();
resolve_with(
&roots,
BuildTarget::host().unwrap(),
&runtime,
&next,
&cache,
&mut lock,
&fixture::verifier(),
)
.unwrap();
assert_eq!(lock.go_sumdb.len(), 2);
let before = lock.encode().unwrap();
let error = resolve_with(
&roots,
BuildTarget::host().unwrap(),
&runtime,
&first,
&cache,
&mut lock,
&fixture::verifier(),
)
.unwrap_err();
assert!(error.contains("rollback"), "{error}");
assert_eq!(lock.encode().unwrap(), before);
}
#[test]
fn module_directives_check_identity_toolchain_and_requirements() {
let source = b"module example.com/a\ngo 1.25\nrequire (\nexample.com/b v1.2.0 // indirect\n)\n";
let parsed = module::requirements(source, "example.com/a", "1.25.0").unwrap();
assert_eq!(parsed["example.com/b"], "1.2.0");
assert!(module::requirements(source, "example.com/other", "1.25.0").is_err());
assert!(module::requirements(source, "example.com/a", "1.24.0").is_err());
for directive in [
"replace example.com/b => ../local",
"exclude example.com/b v1.0.0",
"require (\nexample.com/b v1.0.0",
"module example.com/a",
] {
assert!(
module::requirements(
format!("module example.com/a\n{directive}\n").as_bytes(),
"example.com/a",
"1.25.0"
)
.is_err()
);
}
assert_eq!(module::escape("example.com/Mixed"), "example.com/!mixed");
}
+74
View File
@@ -0,0 +1,74 @@
//! 中间归档只检查文件属性;读取计数与异常文件覆盖不启动 Go 工具。
use super::*;
use std::os::unix::fs::symlink;
fn stage() -> Stage {
let root = std::env::temp_dir().join(format!(
"dever-go-output-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
Stage {
root,
resources: BTreeMap::new(),
written: 0,
started: Instant::now(),
}
}
#[test]
fn go_intermediate_output_checks_metadata_without_reading_archive() {
let stage = stage();
let path = stage.root.join("package.a");
let length = 8 * 1024 * 1024;
fs::File::create(&path).unwrap().set_len(length).unwrap();
let read_chars = || {
fs::read_to_string("/proc/thread-self/io")
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("rchar: "))
.unwrap()
.parse::<u64>()
.unwrap()
};
let before = read_chars();
drop(stage.output(&path).unwrap());
let consumed = read_chars() - before;
eprintln!("go intermediate: fixture=sparse-zero bytes={length} rchar={consumed}");
assert!(
consumed < 4096,
"read {consumed} bytes for a metadata check"
);
assert_eq!(fs::metadata(path).unwrap().len(), length);
}
#[test]
fn go_output_rejects_links_empty_special_and_oversized_files() {
let stage = stage();
let path = stage.root.join("output");
fs::write(&path, b"archive").unwrap();
drop(stage.output(&path).unwrap());
let link = stage.root.join("symbolic");
symlink(&path, &link).unwrap();
assert!(stage.output(&link).is_err());
fs::remove_file(link).unwrap();
let link = stage.root.join("hard");
fs::hard_link(&path, &link).unwrap();
assert!(stage.output(&path).is_err());
fs::remove_file(link).unwrap();
assert!(stage.output(&stage.root).is_err());
fs::File::create(&path).unwrap();
assert!(stage.output(&path).is_err());
fs::File::create(&path)
.unwrap()
.set_len(128 * 1024 * 1024 + 1)
.unwrap();
assert!(stage.output(&path).is_err());
fs::remove_file(&path).unwrap();
assert!(stage.output(&path).is_err());
}
+74
View File
@@ -0,0 +1,74 @@
//! 官方历史签名向量与目录哈希检查,独立于自签测试对端。
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD};
use std::io::{Cursor, Write};
#[test]
fn official_inclusion_and_consistency_vectors_reject_each_tampered_hash() {
let evidence: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
let verified = evidence.verify(&Verifier::official()).unwrap();
assert!(verified.contains("github.com/google/uuid", "1.6.0"));
assert!(evidence.verify(&fixture::verifier()).is_err());
assert_eq!(
sumdb::mod_hash(b"module github.com/google/uuid\n"),
"h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo="
);
for index in 0..evidence.records[0].inclusion.len() {
let mut changed = evidence.clone();
changed.records[0].inclusion[index] = STANDARD.encode([0; 32]);
assert!(changed.verify(&Verifier::official()).is_err());
}
for index in 0..evidence.consistency.len() {
let mut changed = evidence.clone();
changed.consistency[index] = STANDARD.encode([0; 32]);
assert!(changed.verify(&Verifier::official()).is_err());
}
let mut changed = evidence.clone();
changed.records[0].go_mod.push_str("// tampered\n");
assert!(changed.verify(&Verifier::official()).is_err());
let mut changed = evidence.clone();
changed.checkpoint = changed.checkpoint.replace("go.sum", "no.sum");
assert!(changed.verify(&Verifier::official()).is_err());
}
#[test]
fn hashzip_tracks_directory_names_but_not_compression_or_order() {
let archive = |reverse: bool, directory: bool| {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut names = vec!["value.go", "LICENSE"];
if reverse {
names.reverse();
}
for name in names {
zip.start_file(
format!("example.com/Mixed@v1.0.0/{name}"),
zip::write::SimpleFileOptions::default().compression_method(if reverse {
zip::CompressionMethod::Deflated
} else {
zip::CompressionMethod::Stored
}),
)
.unwrap();
zip.write_all(b"same bytes").unwrap();
}
if directory {
zip.add_directory(
"example.com/Mixed@v1.0.0/empty/",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
}
zip.finish().unwrap().into_inner()
};
let first = sumdb::zip_hash("example.com/Mixed", "1.0.0", &archive(false, false)).unwrap();
assert_eq!(
first,
sumdb::zip_hash("example.com/Mixed", "1.0.0", &archive(true, false)).unwrap()
);
assert_ne!(
first,
sumdb::zip_hash("example.com/Mixed", "1.0.0", &archive(false, true)).unwrap()
);
assert!(sumdb::zip_hash("example.com/mixed", "1.0.0", &archive(false, false)).is_err());
}
+54
View File
@@ -0,0 +1,54 @@
// 测试 pack 的固定目标源码选择器;产品只运行签名 pack 提供的对应工具。
package main
import (
"encoding/json"
"fmt"
"go/build"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
func main() {
if err := selectPackage(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
}
func selectPackage() error {
if len(os.Args) != 4 { return fmt.Errorf("expected OS architecture directory") }
context := build.Default
context.GOOS, context.GOARCH = os.Args[1], os.Args[2]
context.GOROOT, context.GOPATH = "", ""
context.CgoEnabled = true
directory := os.Args[3]
selected, err := context.ImportDir(directory, 0)
if err != nil { return err }
if len(selected.CgoFiles)+len(selected.CFiles)+len(selected.CXXFiles)+len(selected.SFiles)+len(selected.SysoFiles) != 0 {
return fmt.Errorf("cgo/assembly/native objects are unsupported")
}
embed := map[string][]string{}
for _, pattern := range selected.EmbedPatterns {
glob := strings.TrimPrefix(pattern, "all:")
if filepath.IsAbs(glob) || strings.Contains(glob, "..") { return fmt.Errorf("invalid embed pattern") }
matches, err := filepath.Glob(filepath.Join(directory, glob))
if err != nil || len(matches) == 0 { return fmt.Errorf("unmatched embed pattern") }
for _, match := range matches {
err = filepath.WalkDir(match, func(path string, entry fs.DirEntry, failure error) error {
if failure != nil { return failure }
if entry.Type()&os.ModeSymlink != 0 { return fmt.Errorf("embed link") }
if entry.IsDir() { return nil }
relative, err := filepath.Rel(directory, path)
if err != nil { return err }
embed[pattern] = append(embed[pattern], filepath.ToSlash(relative))
return nil
})
if err != nil { return err }
}
sort.Strings(embed[pattern])
}
imports := selected.Imports
if imports == nil { imports = []string{} }
return json.NewEncoder(os.Stdout).Encode(map[string]any{"name":selected.Name,"files":selected.GoFiles,"imports":imports,"embed":embed})
}
+68
View File
@@ -0,0 +1,68 @@
{
"previous": "go.sum database tree\n63512409\nF7eHRPqN/cqPtqti14NKdGGaI1Giw8coWP3KxihFZbg=\n\n— sum.golang.org Az3gri1OBHKT87Xj8UXU8zjoHGy/X+7Nl94f8TZkELVaasolmGApUsWqwVG9vE6hgqYb/ph/w2vdkLIVRH7bMB/FPQ4=\n",
"checkpoint": "go.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
"consistency": [
"3OyjS9XS6r41pxaFWGnW/WSmX7b1ik3nupquDNipdZ8=",
"zX5IsB02+IutT35ka/MwY7gFiPSAlihfnt2cu6smUjw=",
"Qc0CqO89A+/onGz1u4ZsDoMOfRIgPZpayRGTETOv8kk=",
"JhdrRO/y8Iu+MDeUupFG0uM4+odAADMjVH0mOgLOA/c=",
"lOgywJvTdrBWol0uphM6ZulvAebwJGNlekm8MIwiwKM=",
"FRf0N51Fw+8eSLHEa/e/AsnDway0EkKo4jI0E1Ne7dk=",
"Ak0gyC+RS6/ahVBCBuSBDA1wFN9mgq3acIbYm9uJHQ4=",
"FFJ8WrKTTQgCjTuAl5CukBdjDaQ7opSdHBpNSolRYWw=",
"rdNcd+MUjUxzlX/nlVGeQP5BkkZyTT89sn/yfg0AXQ0=",
"gJzOh5y5rP1BILUrFJw+aGjod840PErDnjy4T0BoUE4=",
"7I8BPI+ADxeJmu19mQxO+xt6bNhg3Ueq1j2dIfOppwA=",
"urhcUF9QOyTRAYRjJjsRHjHdwIc+BxfiQ6a+NuYeC6E=",
"p9eGEUHXeKLFnrIPB7VXHHMuwtaBnvHG4CYwgC0wq+w=",
"Gcry0ScaNfFmQujPMT95RQ3rbzKDSynQsL546C8CkfU=",
"dIpym+JRT/+z9/tlhpuIRyv+uoWGqgND/MHw0f1MtqA=",
"cQrKLJIBbPoWDnRnbGfAEMfpsau0h+Aw3aqDOJ5LAVU=",
"C46761Ui7jL+lf4vRXBlveoxuFS6tykAd6mMEfNttPE=",
"45Kz4HcZ9iZ84ySMHCy21d4vd2MvibvfNMzYm0hKYRs=",
"4JZTJ+4VUtLMTEs11jn4GmCxoz56Lm5HDwkYmkmAWlw=",
"2hJV4h1jZ2YujjyXUw+7YYCwLXGYZy+ondhRUgxKmXI=",
"lrn/kRfgYdYSDxgPdSQ8/GnZw6u/joKu9++AR+Saa5M=",
"hnm9+kaHQdhHWrAcyrvY5fbv8QB+GwVjRMFeobtTg8A=",
"rPFmKL/vD6Edsk/0DmpvDa/nMOqWgjfpPGRjBUL9yKs=",
"2IzbyezmwwGZZPJ276ejAQZiAnar+RcjAUU3MEMtbIA=",
"k3kfr9AECmkV+UA3/D6EMqaBDr67aubL5hXGsvJ+Nng=",
"/WnQtddEqG03mbcU1XID1iws1nlKHVkJcaREWknCOiE=",
"xJ20xXM4QItt5vxFOtolZ5l18p7ej1+RimNkgWmoRFM="
],
"records": [{
"module": "github.com/google/uuid",
"version": "1.6.0",
"go_mod": "module github.com/google/uuid\n",
"lookup": "22152757\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\n\ngo.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
"inclusion": [
"vYU6vRfhcMyd5/U04n+AYg52mm3BQrjwyN+27saiSnU=",
"syWiyNbj6gybIdLKoAFd7xF3qNltS7OhQjtp4y8rCFg=",
"Zqpa9zJsOg/sI/mv0UkXO9d7gvPHLLjMAKnZCPzeiNM=",
"tGbcXa1AQlYBIm5gM2KDy8JvdAFDizj4lUop18Eehxs=",
"/w9FG1Sae/omKxZdOvj719sT1rCow3tlP8VNo0fDQtE=",
"dR6Bk+gh/hyJx7bu65HfhQdfuvT/OdVv/G7RUsOc26Y=",
"fRC86/2mWWtFWNPz/Vou6f7hdyvRLHFPo9KaOlVOVmc=",
"jJS2b3XVP2sst4CS5srpnF34agHpiluU63h6UQyFPDc=",
"cOL2+zAXTEOaAiyvmCrL3Ned6soAg08CJJJ2VW3+a18=",
"In5NzPUzIAZfV6bnUL11Bkm4Aqoit/ZG7gJ8nxOdB2w=",
"r+8IH0m7Ig3BS+uwCVVG1CZT0I/Rro3bbSUVYcSrN2Q=",
"wOgTamt00bdokE1Nbv3aHYu2um06V1xdLFEmOBjt0GI=",
"21woobbbCvogV/ZXACdAozfVAFjRv5xMID/iLcryHzw=",
"5+o2gn9/zVod+MYr4UuoeMzuz68vt/hA5+9H4YZaVME=",
"040B+RrLQeFCJhPU69kzoKr4BTqhSwdrnYEVhknZ+I0=",
"ioI6UByPBEr2lEZB0dI6pCXgcNss6UkKxm+TYAADujg=",
"HOJ0dLK4hnl0tFeu1tmT7TRrNKRIbi/T8KI/2s4DXDs=",
"BBcHtB4mS19jHff/LQTNI/+WCVBZ1Up52gNqyoln71U=",
"Oz/OlF1Lj7wkqqE15p324ZSX1XHcnLzDSMKNtUx6rwY=",
"ofpmTAcvFvNzlwjZnsrMqeG4iIBCP2lt5wIusvZ2gG4=",
"IRpVS0aap6n0qb02jyOy6ikkOPxdFIQtWwckaGF2MAQ=",
"liRusDUG3TanNZLGkfjb58ppX/6d40M7X2s3wptjxMw=",
"ROjUzjXHDN0Yo/I6EeqHhpszFgIs7FCABVeQniliPTk=",
"NTjIv8CJqIIGrOHhNQWSP9FV6XEPL6uGA4lmMfNKGJg=",
"bwqUQG2wJXGxo1QLA2CFb3chH17xvlqXe6eQy43p0HY=",
"bqa+4xGvzGcI/PYne2fXfUvdULgy8o3zQmD13q/gu1Y="
],
"consistency": []
}]
}
@@ -0,0 +1,13 @@
# value
本领域的职责与行为见主题说明;函数签名和调用关系从源码读取。
## 说明
- [Adapter 运行配置验证](doc/adapter-local.md)
- [Adapter 运行配置验证](doc/adapter-remote.md)
- [Adapter 运行配置验证](doc/api.md)
- [Adapter 运行配置验证](doc/app.md)
- [Adapter 运行配置验证](doc/port.md)
[调用流程](link.md)由 `dever inspect . --links` 生成。
@@ -0,0 +1,29 @@
setting {
offset: Int
prefix: Text
token: Secret
}
port.calculate(value: Int) (answer: Int) {
answer = helper(value)
}
port.echo(value: Text) (answer: Text) {
answer = setting.prefix + value
}
port.sign(value: Text) (answer: Int?) {
digest = dever.crypto.hmacSha256(setting.token, dever.bytes.fromText(value))
answer = dever.bytes.at(digest, 0)
}
port.notify(value: Int) () {
check(value + setting.offset)
}
helper(value: Int) (answer: Int) {
answer = value + setting.offset
}
check(value: Int < 0) () { fail(app.Problem.Missing("negative")) }
check(value: other) () {}
@@ -0,0 +1,20 @@
port.calculate(value: Int) (answer: Int) {
answer = value + 100
}
port.echo(value: Text) (answer: Text) {
answer = wait(run(label(value)))
}
port.sign(value: Text) (answer: Int?) {
answer = dever.bytes.at(dever.bytes.fromText("R"), 0)
}
port.notify(value: Int) () {
check(value)
}
check(value: Int < 0) () { fail(app.Problem.Missing("negative")) }
check(value: other) () {}
label(value: Text) (answer: Text) { answer = "remote:" + value }
@@ -0,0 +1,17 @@
cmd calculate = app.calculate
cmd echo = app.echo
cmd sign = app.sign
cmd worker = app.worker
cmd nested = app.nested
cmd mapped = app.mapped
cmd parallel = app.parallel
cmd scheduled = app.scheduled
cmd cancelled = app.cancelled
@@ -0,0 +1,44 @@
type Problem {
error Missing(message: Text)
error Denied(code: Int)
}
calculate(value: Int) (answer: Int) {
answer = port.calculate(value)
}
echo(value: Text) (answer: Text) {
answer = port.echo(value)
}
sign(value: Text) (answer: Int?) {
answer = port.sign(value)
}
worker(value: Int) (answer: Int) {
answer = wait(run(port.calculate(value)))
}
nested(value: Int) (answer: Int) {
answer = wait(run(worker(value)))
}
mapped(value: Int) (answer: Int) {
answer = sum(each(port.calculate, [value, value]))
}
parallel(value: Int) (answer: Int) {
parallelEach(port.notify, [value, value], 2)
answer = port.calculate(value)
}
scheduled(value: Int) (answer: Int) {
answer = wait(run(port.calculate(value)))
parallelEach(port.notify, [value, value], 2)
}
cancelled(value: Int) (answer: Int) {
pending = run(port.notify(value))
stop(pending)
answer = port.calculate(value)
}
@@ -0,0 +1,64 @@
# Adapter 运行配置验证
验证同一原生产物按启动配置选择实现。
## demo.value.adapter.local.Setting
仅选中本实现时读取配置,Secret 不参与公开输出。
字段:
- `offset`:数值偏移
- `prefix`:文本前缀
- `token`:签名测试输入
## demo.value.adapter.local.port.calculate
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.local.port.echo
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.local.port.sign
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.local.port.notify
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:无
## demo.value.adapter.local.helper
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.local.check
检查并行回调确实读取配置并保留错误身份。
输入:
- `value`:输入值
输出:无
@@ -0,0 +1,55 @@
# Adapter 运行配置验证
验证同一原生产物按启动配置选择实现。
## demo.value.adapter.remote.port.calculate
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.remote.port.echo
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.remote.port.sign
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.adapter.remote.port.notify
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:无
## demo.value.adapter.remote.check
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:无
## demo.value.adapter.remote.label
异步候选拥有输入文本,配置选择不会改变输出协议。
输入:
- `value`:输入值
输出:
- `answer`:结果
@@ -0,0 +1,39 @@
# Adapter 运行配置验证
验证同一原生产物按启动配置选择实现。
### calculate
验证实际编译运行结果。
### echo
验证实际编译运行结果。
### sign
验证实际编译运行结果。
### worker
验证实际编译运行结果。
### nested
验证实际编译运行结果。
### mapped
验证实际编译运行结果。
### parallel
验证实际编译运行结果。
### scheduled
验证实际编译运行结果。
### cancelled
验证取消后的配置生命周期。
@@ -0,0 +1,92 @@
# Adapter 运行配置验证
验证同一原生产物按启动配置选择实现。
## demo.value.Problem
保持完整的业务错误合同。
分支:
- `error Missing(message: Text)`:输入不可用
- `error Denied(code: Int)`:拒绝原因
## demo.value.calculate
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.echo
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.sign
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.worker
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.nested
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.mapped
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.parallel
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.scheduled
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
## demo.value.cancelled
取消持有配置的子任务后仍可读取父调用的配置。
输入:
- `value`:输入值
输出:
- `answer`:结果
@@ -0,0 +1,42 @@
# Adapter 运行配置验证
验证同一原生产物按启动配置选择实现。
## demo.value.port.calculate
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
允许失败:`app.Problem`
## demo.value.port.echo
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
允许失败:`app.Problem`
## demo.value.port.sign
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:
- `answer`:结果
允许失败:`app.Problem`
## demo.value.port.notify
覆盖配置、选择和调用生命周期。
输入:
- `value`:输入值
输出:无
允许失败:`app.Problem`
@@ -0,0 +1,7 @@
calculate(value: Int) (answer: Int) fails app.Problem
echo(value: Text) (answer: Text) fails app.Problem
sign(value: Text) (answer: Int?) fails app.Problem
notify(value: Int) () fails app.Problem
@@ -0,0 +1,10 @@
# wire
本领域的职责与行为见主题说明;函数签名和调用关系从源码读取。
## 说明
- [HTTP 编解码入口](doc/api.md)
- [HTTP 编解码性能样例](doc/app.md)
[调用流程](link.md)由 `dever inspect . --links` 生成。
@@ -0,0 +1,3 @@
public post echo = app.echo
public post count = app.count
@@ -0,0 +1,5 @@
type Payload { text: Text, value: Int }
echo(payload: Payload) (answer: Payload) { answer = payload }
count(values: List<Int>) (answer: Int) { answer = length(values) }
@@ -0,0 +1,11 @@
# HTTP 编解码入口
绑定无数据库、无认证的公开请求。
### echo
通过 JSON 正文接收并返回记录。
### count
通过 JSON 正文接收整数数组并返回长度。
@@ -0,0 +1,29 @@
# HTTP 编解码性能样例
比较固定结构请求与响应的完整生成入口。
## bench.wire.Payload
固定字段用于验证结构预算可由类型证明。
字段:
- `text`:原样返回的文本
- `value`:原样返回的数字
## bench.wire.echo
原样返回请求中的具体记录。
输入:
- `payload`:请求记录
输出:
- `answer`:相同记录
## bench.wire.count
接收可变长度数组,读取长度,单独测量输入解码成本。
输入:
- `values`:待计数的整数
输出:
- `answer`:元素数量
@@ -0,0 +1,11 @@
# greeting
本领域的职责与行为见主题说明;函数签名和调用关系从源码读取。
## 说明
- [应用命令样例](doc/api.md)
- [应用命令样例](doc/app.md)
- [应用命令样例](doc/domain.md)
[调用流程](link.md)由 `dever inspect . --links` 生成。
@@ -0,0 +1,17 @@
cmd greet = app.greet
cmd double = app.double
cmd echo = app.echo
cmd many = app.many
cmd divide = app.divide
cmd pair = app.pair
cmd empty = app.empty
cmd positive = app.positive
cmd maybe = app.maybe
@@ -0,0 +1,29 @@
type Profile {
id: Id
name: Text
scores: List<Int>
ratio: Float?
}
type Problem { error Rejected(message: Text) }
greet(name: Text) (message: Text) {
message = utility.decorate(domain.greet(name))
}
double(value: Int) (answer: Int) { answer = domain.double(value) }
echo(value: Profile) (answer: Profile) { answer = value }
many(rows: List<Profile>) (answer: List<Profile>) { answer = rows }
divide(value: Float, divisor: Float) (answer: Float) { answer = value / divisor }
pair(value: Int) (first: Int, second: Int) { first = value
second = value + 1 }
empty() () {}
positive(value: Int) (answer: Int) { answer = domain.positive(value) }
maybe(value: Int?) (answer: Int?) { answer = value }
@@ -0,0 +1,39 @@
# 应用命令样例
验证应用调用边界与类型化命令执行。
### greet
将命令绑定到本领域能力。
### double
将命令绑定到本领域能力。
### echo
将命令绑定到本领域能力。
### many
将命令绑定到本领域能力。
### divide
将命令绑定到本领域能力。
### pair
将命令绑定到本领域能力。
### empty
将命令绑定到本领域能力。
### positive
将命令绑定到本领域能力。
### maybe
验证可空标量从命令解码结果借用后仍按值返回。
@@ -0,0 +1,101 @@
# 应用命令样例
验证应用调用边界与类型化命令执行。
## demo.greeting.Profile
定义命令传输的业务数据。
字段:
- `id`:输入或输出的业务值
- `name`:输入或输出的业务值
- `scores`:输入或输出的业务值
- `ratio`:输入或输出的业务值
## demo.greeting.Problem
定义命令传输的业务数据。
分支:
- `error Rejected(message: Text)`:输入或输出的业务值
## demo.greeting.greet
完成命令对应的业务处理。
输入:
- `name`:输入或输出的业务值
输出:
- `message`:输入或输出的业务值
## demo.greeting.double
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.echo
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.many
完成命令对应的业务处理。
输入:
- `rows`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.divide
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
- `divisor`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.pair
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `first`:输入或输出的业务值
- `second`:输入或输出的业务值
## demo.greeting.empty
完成命令对应的业务处理。
输入:无
输出:无
## demo.greeting.positive
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.maybe
保持可空整数的完整值,不给空输入填入默认值。
输入:
- `value`:可空整数
输出:
- `answer`:原始值
@@ -0,0 +1,30 @@
# 应用命令样例
验证应用调用边界与类型化命令执行。
## demo.greeting.domain.greet
完成命令对应的业务处理。
输入:
- `name`:输入或输出的业务值
输出:
- `message`:输入或输出的业务值
## demo.greeting.domain.double
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
## demo.greeting.domain.positive
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
@@ -0,0 +1,6 @@
greet(name: Text) (message: Text) { message = "Hello, " + name }
double(value: Int) (answer: Int) { answer = value * 2 }
positive(value: Int < 0) (answer: Int) { fail(app.Problem.Rejected("negative")) }
positive(value: other) (answer: Int) { answer = value }
@@ -0,0 +1,9 @@
# utility
本领域的职责与行为见主题说明;函数签名和调用关系从源码读取。
## 说明
- [应用命令样例](doc/app.md)
[调用流程](link.md)由 `dever inspect . --links` 生成。
@@ -0,0 +1 @@
decorate(value: Text) (answer: Text) { answer = "[" + value + "]" }
@@ -0,0 +1,12 @@
# 应用命令样例
验证应用调用边界与类型化命令执行。
## demo.utility.decorate
完成命令对应的业务处理。
输入:
- `value`:输入或输出的业务值
输出:
- `answer`:输入或输出的业务值
+52
View File
@@ -0,0 +1,52 @@
add(value: Int, state: Int) (next: Int) pure { next = value + state }
type Message { Value(value: Int?) }
count(value: Message, state: Int) (next: Int) pure { next = state + 1 }
until(value: Int, state: Int) (next: Int, stop: Bool) pure { next = state + value
stop = value >= 2 }
delayed(value: Int, state: Int) (next: Int) { dever.task.sleep(0)
next = state + value }
sendOne(value: Int, target: Channel<Int>) () { send(target, value) }
bad(value: Int) () { dever.task.sleep(-1) }
consumeBad(values: AsyncStream<Int>) () { each(bad, values) }
failed(value: dever.time.SleepResult.Done) (answer: Bool) recover("观察流失败") { answer = false }
failed(value: dever.time.SleepResult.Failed(message)) (answer: Bool) recover("观察流失败") { answer = length(message) > 0 }
cleanup() (answer: Bool) { values = stream([1, 2])
failure = failed(result(consumeBad(values)))
remaining = reduce(add, values, 0)
answer = failure and remaining == 0 }
creation() (values: AsyncStream<Int>) { values = dever.task.ticks(1) }
invalid() () { close(dever.task.ticks(0)) }
verify() (answer: Bool) {
values = stream([1, 2, 3])
alias = values
total = reduce(add, values, 0)
empty = reduce(add, alias, 0)
limited = stream([1, 2, 3])
prefix = reduceUntil(until, limited, 0)
after = reduce(add, limited, 0)
async_total = reduce(delayed, stream([1, 2, 3]), 0)
bytes_total = reduce(add, stream(dever.bytes.fromText("AB")), 0)
channel_values = channel(Message, 2)
send(channel_values, Message.Value(null))
send(channel_values, Message.Value(2))
close(channel_values)
items = reduce(count, stream(channel_values), 0)
target = channel(Int, 2)
each(sendOne, stream([10, 20]), target)
first = receive(target)
second = receive(target)
ticks = reduceUntil(until, dever.task.ticks(1), 0)
answer = total == 6 and empty == 0 and prefix == 3 and after == 0 and async_total == 6 and bytes_total == 131 and items == 2 and first == 10 and second == 20 and ticks == 3
}
@@ -0,0 +1,13 @@
# account
本领域的职责与行为见主题说明;函数签名和调用关系从源码读取。
## 说明
- [认证基准接口](doc/api-admin.md)
- [认证基准命令](doc/api.md)
- [Provider 与角色基准](doc/app.md)
- [身份租户类型](doc/model-tenant.md)
- [认证基准账号](doc/model.md)
[调用流程](link.md)由 `dever inspect . --links` 生成。
@@ -0,0 +1 @@
cmd seed = app.seed
@@ -0,0 +1 @@
get permissions = app.permissions
@@ -0,0 +1,22 @@
type Identity { id: Text
user_id: model.id?
tenant_id: model.tenant.id?
}
verify(claims: dever.auth.Claims) (verified: Identity) {
account = model.first({ where = subject == claims.subject and session == claims.session and active == true })
verified = identity(account)
}
identity(account: model.Account) (verified: Identity) {
verified = Identity { id = account.subject, user_id = account.id, tenant_id = null }
}
identity(account: null) (verified: Identity) { fail(dever.api.Error.Unauthorized) }
seed(subject: Text, session: Text, active: Bool) (account: model.Account) {
account = model.create({ subject = subject, session = session, active = active })
}
permissions() (values: List<dever.auth.Permission>) {
values = dever.auth.currentPermissions()
}
@@ -0,0 +1,7 @@
# 认证基准接口
受保护入口使生成器建立真实权限目录和认证分派。
### permissions
读取当前身份的角色授权。
@@ -0,0 +1,7 @@
# 认证基准命令
独立测试入口,不创建生产账号。
### seed
建立基准账号。
@@ -0,0 +1,43 @@
# Provider 与角色基准
保留真实 Dever 条款分派、SQL decoder 和标准库记录转换。
## bench.account.Identity
可信身份不携带权限缓存。
字段:
- `id`:身份标识
- `user_id`:账号 ID
- `tenant_id`:租户 ID
## bench.account.verify
从数据库重新验证会话。
输入:`claims`:签名已验证的声明
输出:`verified`:身份
## bench.account.identity
缺失、已禁用或会话不匹配的账号均拒绝。
输入:`account`:账号
输出:`verified`:身份
## bench.account.seed
在计时前准备独立数据库。
输入:
- `subject`:身份
- `session`:会话
- `active`:启用状态
输出:`account`:账号
## bench.account.permissions
读取当前用户的实际角色授权,输出所有记录字段。
输入:无
输出:`values`:有效权限
@@ -0,0 +1,9 @@
# 身份租户类型
用于保持身份记录的名义 ID 合同;本基准不建立租户。
## bench.account.model.tenant.Tenant
独立的租户 ID 类型。
字段:`name`:名称
@@ -0,0 +1,12 @@
# 认证基准账号
独立基准数据,真实读取账号、会话和启用状态。
## bench.account.model.Account
Provider 只查询全局身份表。
字段:
- `subject`:身份
- `session`:会话
- `active`:启用状态
@@ -0,0 +1,5 @@
global type Account {
subject: Text unique
session: Text
active: Bool
}
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff. Show more