Module-level assertions (#8)

I had a usecase for assertions in the module system for my [trivial builders PR in tidepool](auxolotl/labs#24), so I added a pretty basic way of defining them.

It's somewhat quick and dirty I feel like, and given lib has hit 1.0 I precautiously added a warning "module assertions are experimental" if a module defines them, which is traced just before checking them. Assertions are not checked if `__module__.check = false`.

I also took the liberty to fixup little things in tests and the module lib (the 3 commits with `fix:`).

Each commit is rather tiny, but I split them up for review. re-formatting is in the last one.

Co-authored-by: austreelis <git@swhaele.net>
Reviewed-on: #8
Reviewed-by: Jake Hamilton <jake.hamilton@hey.com>
Co-authored-by: Austreelis <austreelis@noreply.git.auxolotl.org>
Co-committed-by: Austreelis <austreelis@noreply.git.auxolotl.org>
This commit is contained in:
Austreelis 2025-10-18 22:21:49 +00:00 committed by Jake Hamilton
parent 97e4032e45
commit 0f5182ff0d
3 changed files with 149 additions and 7 deletions

View file

@ -101,7 +101,7 @@ in
];
};
in
(builtins.trace (builtins.deepSeq actual actual)) actual == expected;
actual == expected;
};
};

View file

@ -219,6 +219,7 @@ lib: {
"__key__"
"includes"
"excludes"
"assertions"
"options"
"config"
"freeform"
@ -240,11 +241,12 @@ lib: {
let
invalid = builtins.removeAttrs module lib.modules.VALID_KEYS;
invalidKeys = builtins.concatStringsSep ", " (builtins.attrNames invalid);
__file__ = builtins.toString module.__file__ or file;
__key__ = builtins.toString module.__key__ or key;
in
if lib.modules.validate.keys module then
{
__file__ = builtins.toString module.__file__ or file;
__key__ = builtins.toString module.__key__ or key;
inherit __file__ __key__;
includes = module.includes or [ ];
excludes = module.excludes or [ ];
options = module.options or { };
@ -271,9 +273,44 @@ lib: {
config;
in
withFreeform (withMeta base);
assertions =
let
type = builtins.typeOf module.assertions;
validateAssert =
assertion:
(builtins.isAttrs assertion)
&& (
builtins.removeAttrs assertion [
"value"
"message"
] == { }
)
&& (assertion ? value)
&& (assertion ? message);
normalizeAssert =
assertion:
assertion
// {
file = __file__;
key = __key__;
};
in
if !module ? assertions then
[ ]
else if type != "list" then
builtins.throw "Module `${__key__}` (${__file__}) has an 'assertions' attribute that is not a list but a ${type}"
else if !builtins.all (validateAssert) module.assertions then
builtins.throw ''
Module `${__key__}` (${__file__}) has invalid assertion(s)
Each assertion should be a set with:
- a 'value' attribute, which indicates the assertion fails if and only if it is not `true`.
- a 'message' attribute containing the message to throw if the assertion fails.
No other attribute is permitted''
else
builtins.map normalizeAssert module.assertions;
}
else
builtins.throw "Module `${key}` (${file}) has unsupported attribute(s): ${invalidKeys}";
builtins.throw "Module `${__key__}` (${__file__}) has unsupported attribute(s): ${invalidKeys}";
/**
Convert a module that is either a function or an attribute set into
@ -520,6 +557,8 @@ lib: {
configs = builtins.concatMap getConfig modules;
assertions = builtins.concatMap (module: module.assertions) modules;
process =
prefix: options: configs:
let
@ -630,7 +669,7 @@ lib: {
);
};
in
process prefix modules configs;
process prefix modules configs // { inherit assertions; };
/**
Run a set of modules. Custom arguments can also be supplied which will
@ -856,6 +895,22 @@ lib: {
''
else
builtins.throw message
else if config.__module__.check && merged.assertions != [ ] then
builtins.trace "[WARN]: module assertions are experimental" builtins.foldl' (
_:
{
value,
message,
file,
key,
}:
if (builtins.tryEval value).value == true then
null
else
builtins.throw ''
Assertion failed in module `${key}` (${file}):
${builtins.toString message}''
) null merged.assertions
else
null;

View file

@ -186,6 +186,7 @@ in
__key__ = "aux/example";
includes = [ ];
excludes = [ ];
assertions = [ ];
options = { };
config = { };
freeform = null;
@ -374,6 +375,7 @@ in
config = { };
excludes = [ ];
includes = [ ];
assertions = [ ];
options = { };
};
actual = lib.modules.normalize "/aux/example.nix" "example" { };
@ -390,6 +392,7 @@ in
};
excludes = [ ];
includes = [ ];
assertions = [ ];
options = { };
};
actual = lib.modules.normalize "/aux/example.nix" "example" {
@ -399,6 +402,29 @@ in
};
in
actual == expected;
"adds details to assertions" =
let
assertions = [
{
value = true;
message = "unreachable";
file = "myfile.nix";
key = "mykey";
}
];
actual = lib.modules.normalize "/aux/example.nix" "example" {
__file__ = "myfile.nix";
__key__ = "mykey";
assertions = [
{
value = true;
message = "unreachable";
}
];
};
in
actual.assertions == assertions;
};
"resolve" = {
@ -445,6 +471,7 @@ in
expected = {
matched = { };
unmatched = [ ];
assertions = [ ];
};
actual = lib.modules.combine [ ] [ (lib.modules.normalize "/aux/example.nix" "example" { }) ];
in
@ -461,6 +488,14 @@ in
value = 1;
}
];
assertions = [
{
value = true;
message = "unreachable";
file = "/aux/example.nix";
key = "example";
}
];
};
actual =
lib.modules.combine
@ -470,6 +505,12 @@ in
config = {
x = 1;
};
assertions = [
{
value = true;
message = "unreachable";
}
];
})
];
in
@ -484,11 +525,25 @@ in
value = 2;
}
];
assertions = [
{
value = true;
message = "unreachable";
file = "/aux/example1.nix";
key = "example1";
}
{
value = true;
message = "unreachable";
file = "/aux/example2.nix";
key = "example2";
}
];
actual =
lib.modules.combine
[ ]
[
(lib.modules.normalize "/aux/example1.nix" "example2" {
(lib.modules.normalize "/aux/example1.nix" "example1" {
options = {
x = lib.options.create { };
};
@ -496,15 +551,29 @@ in
config = {
x = 1;
};
assertions = [
{
value = true;
message = "unreachable";
}
];
})
(lib.modules.normalize "/aux/example2.nix" "example2" {
config = {
y = 2;
};
assertions = [
{
value = true;
message = "unreachable";
}
];
})
];
in
(actual.unmatched == unmatched) && actual.matched ? x;
(actual.unmatched == unmatched) && actual.matched ? x && (actual.assertions == assertions);
};
"run" = {
@ -868,5 +937,23 @@ in
};
in
(evaluated.config.aux.message == evaluated.config.aux.message2) && evaluated.config.aux.exists;
"check assertions" =
let
evaluated = lib.modules.run {
modules = [
{
assertions = [
{
value = false;
message = "spooky";
}
];
}
];
};
in
(builtins.tryEval evaluated).success == true
&& (builtins.tryEval evaluated.config).success == false;
};
}