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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -4153,6 +4153,14 @@ type keys, and every other consumer of solved variable identity observe that
checked representative exactly as they did before structural balancing. They
must never observe or infer meaning from the storage root.

An expression whose type is the type of one of its operands (a lookup of a
binding, a block's final value, a record update's base) relates its own
variable as the first operand, so the operand's class keeps its checked
representative and the expression joins it as an occurrence. A later mismatch
at the expression, such as against an annotation, then poisons only that
occurrence (`Store.poisonOnMismatch`) and never the binding the operand reads,
which other definitions may already have used.

The storage root is selected by a separate union-find rank. This structural
rank is not `Descriptor.rank`: descriptor rank is the Hindley-Milner scope level
used to decide generalization, while union-find rank is private data-structure
Expand Down
8 changes: 6 additions & 2 deletions src/check/Check.zig
Original file line number Diff line number Diff line change
Expand Up @@ -29057,8 +29057,12 @@ fn resumeRecordUpdateCheck(self: *Self, task: *ExprTask, state: *RecordUpdateChe
for (fields) |field_idx| update_operands.appendAssumeCapacity(self.cir.store.getRecordField(field_idx).value);
if (try self.retireCallLikeExprWithErroneousOperands(frame.expr_idx, expr_var, update_operands.items)) return .done;

// Then unify with the actual expression
_ = try self.unify(record_being_updated_var, expr_var, env);
// The update is an occurrence of the base's type, so it joins the base's
// class as an occurrence (like a lookup or a block's final value) and the
// base keeps the checked representative. A later mismatch at the update,
// such as against an annotation, then poisons only the update and not the
// binding the base reads.
_ = try self.unify(expr_var, record_being_updated_var, env);
return .done;
}

Expand Down
84 changes: 84 additions & 0 deletions src/check/test/rejected_relation_owner_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,87 @@ test "a top-level literal pattern whose conversion fails retires the definition"
}
try std.testing.expectEqual(@as(usize, 1), retired_defs);
}

// A record update is an occurrence of its base's type. Its mismatch with an
// annotation poisons only the update, never the class of the binding that the
// base reads, which an earlier constant already used.
test "a record update that mismatches its annotation leaves its base binding intact" {
const source =
\\Vet : { years : U8 }
\\
\\label : Vet -> Str
\\label = |_vet| "vet"
\\
\\ruth : Vet
\\ruth = { years: 12 }
\\
\\ruth_label = label(ruth)
\\
\\old_vet : { years : I64 }
\\old_vet = { ..ruth, years: 30 }
;
var test_env = try TestEnv.init("Test", source);
defer test_env.deinit();

try test_env.assertOneTypeError("Type Mismatch");
try test_env.assertDefTypeOptions("ruth", "Vet", .{ .allow_type_errors = true });
try test_env.assertDefTypeOptions("ruth_label", "Str", .{ .allow_type_errors = true });
}

test "a record update that mismatches its annotation leaves a base used by Str.inspect intact" {
const source =
\\ruth : { years : U8 }
\\ruth = { years: 12 }
\\
\\ruth_label = Str.inspect(ruth)
\\
\\old_vet : { years : I64 }
\\old_vet = { ..ruth, years: 30 }
;
var test_env = try TestEnv.init("Test", source);
defer test_env.deinit();

try test_env.assertOneTypeError("Type Mismatch");
try test_env.assertDefTypeOptions("ruth", "{ years: U8 }", .{ .allow_type_errors = true });
}

test "a record update in a function body that mismatches the result annotation leaves the base's alias intact" {
const source =
\\Vet : { years : U8 }
\\
\\label : Vet -> Str
\\label = |_vet| "vet"
\\
\\older : Vet -> { years : I64 }
\\older = |vet| { ..vet, years: 30 }
;
var test_env = try TestEnv.init("Test", source);
defer test_env.deinit();

try test_env.assertOneTypeError("Type Mismatch");
try test_env.assertDefTypeOptions("label", "Vet -> Str", .{ .allow_type_errors = true });
try test_env.assertDefTypeOptions("older", "Vet -> { years: I64 }", .{ .allow_type_errors = true });
}

test "a record update checked before the constant that uses its base reports the mismatch" {
const source =
\\Vet : { years : U8 }
\\
\\label : Vet -> Str
\\label = |_vet| "vet"
\\
\\ruth : Vet
\\ruth = { years: 12 }
\\
\\old_vet : { years : I64 }
\\old_vet = { ..ruth, years: 30 }
\\
\\ruth_label = label(ruth)
;
var test_env = try TestEnv.init("Test", source);
defer test_env.deinit();

try test_env.assertOneTypeError("Type Mismatch");
try test_env.assertDefTypeOptions("ruth", "Vet", .{ .allow_type_errors = true });
try test_env.assertDefTypeOptions("ruth_label", "Str", .{ .allow_type_errors = true });
}
1 change: 1 addition & 0 deletions src/cli/test/parallel_cli_runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2868,6 +2868,7 @@ const subcommand_cases = [_]CliCase{
.{ .id = 0, .suite = .subcommands, .name = "annotation-only decls in a non-platform type module are not flagged as effectful", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/AnnoOnlyNotEffectful.roc", .exit = .failure, .stderr_min_len = 1, .contains = &.{.{ .stream = .stderr, .text = "declaration has no value" }}, .not_contains = &.{.{ .stream = .stderr, .text = "effectful function name" }} } } },
.{ .id = 0, .suite = .subcommands, .name = "issue 10766: calling an annotation-only declaration reports it instead of crashing post-check", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/Issue10766CalledAnnotationOnly.roc", .exit = .failure, .stderr_min_len = 1, .contains = &.{.{ .stream = .stderr, .text = "declaration has no value" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "panic" }, .{ .stream = .stderr, .text = "instantiation unified a primitive type with a non-primitive type" }, .{ .stream = .stderr, .text = "reached unreachable code" } } } } },
.{ .id = 0, .suite = .subcommands, .name = "issue 10766: referencing an annotation-only value reports it instead of crashing post-check", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/Issue10766AnnotationOnlyValue.roc", .exit = .failure, .stderr_min_len = 1, .contains = &.{.{ .stream = .stderr, .text = "declaration has no value" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "panic" }, .{ .stream = .stderr, .text = "instantiation function read had a non-function node" }, .{ .stream = .stderr, .text = "reached unreachable code" } } } } },
.{ .id = 0, .suite = .subcommands, .name = "a record update that mismatches its annotation reports it instead of crashing post-check", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/RecordUpdateAnnotationMismatch.roc", .exit = .failure, .stderr_min_len = 1, .contains = &.{ .{ .stream = .stderr, .text = "type mismatch" }, .{ .stream = .stderr, .text = "1 error and 0 warnings" } }, .not_contains = &.{ .{ .stream = .stderr, .text = "panic" }, .{ .stream = .stderr, .text = "erroneous checked type reached Monotype instantiation" } } } } },
.{ .id = 0, .suite = .subcommands, .name = "roc run prints warning diagnostics once (issue 9509)", .body = .{ .command = .{ .args = &.{"--no-cache"}, .roc_file = "test/cli/Issue9509WarningOnly.roc", .exit = .{ .code = 2 }, .stderr_min_len = 1, .occurrences = &.{ .{ .stream = .stderr, .text = "unused variable", .count = 1 }, .{ .stream = .stderr, .text = "0 errors and 1 warning", .count = 1 } } } } },
.{ .id = 0, .suite = .subcommands, .name = "issue 9898: large nested List.repeat lowers without local span overflow", .body = .{ .command = .{ .args = &.{"--no-cache"}, .roc_file = "test/cli/issue_9898_large_nested_list_repeat.roc", .exit = .success, .not_contains = &.{ .{ .stream = .stderr, .text = "integer does not fit in destination type" }, .{ .stream = .stderr, .text = "panic" } } } } },
.{ .id = 0, .suite = .subcommands, .name = "issue 9883: imported type alias works as Try error from split module", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/issue_9883_imported_alias_try_error/Main.roc", .exit = .success, .not_contains = &.{ .{ .stream = .stderr, .text = "Segmentation fault" }, .{ .stream = .stderr, .text = "panic" } } } } },
Expand Down
22 changes: 22 additions & 0 deletions test/cli/RecordUpdateAnnotationMismatch.roc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Regression test: a record update `{ ..ruth, years: 30 }` that mismatches its
# annotation must report the type mismatch. It must not poison the type of
# `ruth`, which the earlier constant `ruth_label` already used, so post-check
# lowering never meets an erroneous type in `ruth_label` or in the base read.

Vet : { years : U8 }

label : Vet -> Str
label = |_vet| "vet"

ruth : Vet
ruth = { years: 12 }

ruth_label = label(ruth)

old_vet : { years : I64 }
old_vet = { ..ruth, years: 30 }

main! = |_args| {
echo!(ruth_label)
Ok({})
}
Loading