From 8b3906a6ea3cc9c9c2b3ca900c8f1fe82910c93a Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Wed, 11 Nov 2026 23:58:25 -0800 Subject: [PATCH 1/5] fix(checks): take the files of a registry entry from the modules that name them The module system gives a module that names no file the file of the module that imports it. The registry wraps an entry in such modules, and the entry of a listed project also passes through the module that `lib.types.deferredModule` merges from the definitions of `caisson.exports.modules`, which names no file and imports several modules. When a configuration module imports such an entry, that merged module carries the file of the configuration module, so the check `registries` counted the definitions of the configuration module as definitions of the entry. The flake-parts configuration of the test flake `nixpkgs-overlay-export` defines `caisson.pkgOverlays.exported` and imports `caisson/nixpkgs`, and the check reported the definition for `caisson/nixpkgs`. A module names its file when its file differs from the file of the module that imports it. The files of an entry are now the files that the modules below the entry name, and a definition belongs to the entry only when one of those files makes it. The file shown for an entry is the first of those files whose module does more than wrap a single module that names its file, so an entry of a listed project shows its module rather than the file of the definition of `caisson.exports.modules`. Co-Authored-By: Claude Opus 5.5 --- lib-overlays/integrations/default.nix | 76 ++++++++++++++++++--------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 55aa934..3d1224e 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -2376,8 +2376,8 @@ # modules it imports. `options` are the options of the evaluation. # A registry entry is a module whose key is # `modules..@` (`keyedModules`). The files - # of an entry are the files of the entry and of every module it - # imports, at any depth. The result holds each entry by registry + # of an entry are the files that the modules the entry imports, + # at any depth, name. The result holds each entry by registry # key, with `project`, `file`, and `defines`: for each option # under `caisson` that a file of the entry defines, the option # as `option`, the files of the entry that define it as `files`, @@ -2399,7 +2399,34 @@ [ ]; caissonOptions = optionsUnder (options.caisson or { }); enabled = builtins.filter (node: !(node.disabled or false)); - filesOf = node: [ node.file ] ++ builtins.concatMap filesOf (enabled node.imports); + # The modules that a module imports, at any depth, that name + # their file, each as `file` and `wraps`. The module system + # gives a module that names no file the file of the module + # that imports it. A module therefore names its file when its + # file differs from the file of the module that imports it. + # `wraps` is true when the module imports a single module and + # that module names its file, as the module that + # `lib.types.deferredModule` makes of each definition does. + namedBelow = + node: + builtins.concatMap ( + imported: + let + importedByIt = enabled imported.imports; + in + ( + if imported.file != node.file then + [ + { + inherit (imported) file; + wraps = builtins.length importedByIt == 1 && (builtins.head importedByIt).file != imported.file; + } + ] + else + [ ] + ) + ++ namedBelow imported + ) (enabled node.imports); entriesIn = nodes: builtins.concatMap ( @@ -2410,29 +2437,28 @@ entryOf = node: let - # The registry wraps the module of an entry in modules that - # each import the next module alone. A wrapper names no - # file, so the module system gives it the file of the - # module that imports the entry, such as the file of the - # configuration that selects it. The module of the entry is - # the first module below the wrappers whose file differs - # from the file of the wrappers, and the files of the entry - # are the files of that module and of every module it - # imports. - below = - wrapper: - let - imported = enabled wrapper.imports; - in - if builtins.length imported != 1 then - wrapper - else if (builtins.head imported).file == node.file then - below (builtins.head imported) + # The registry wraps the module of an entry in modules + # that name no file, and the module system gives each + # wrapper the file of the module that imports the entry. + # When a configuration module imports the entry, the + # wrappers carry the file of that configuration module, + # and the definitions that the configuration module makes + # carry the same file. The files of the entry are therefore + # the files that the modules below the entry name + # (`namedBelow`), and a definition belongs to the entry + # only when one of these files makes it. The file shown + # for the entry is the first of these files whose module + # does more than wrap a single module that names its file. + named = namedBelow node; + shown = builtins.filter (module: !module.wraps) named; + file = + if shown != [ ] then + (builtins.head shown).file + else if named != [ ] then + (builtins.head named).file else - builtins.head imported; - module = below node; - inherit (module) file; - files = unique (filesOf module); + node.file; + files = unique (builtins.map (module: module.file) named); inEntry = file: builtins.elem file files; defined = builtins.filter (option: builtins.any inEntry option.files) caissonOptions; registered = registryPartsOf node.key; From c3836ce084273d04ab743a90b655f8d3ceef9aa6 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Wed, 11 Nov 2026 23:58:25 -0800 Subject: [PATCH 2/5] test(checks): check registries against a module that imports an entry and defines an option The module `importer` of tests/pinned-world/registries imports the entry `caisson/activation-marker` of the project caisson and defines `caisson.importerNote`, which a file beside it declares. The check `registriesIgnoresTheDefinitionsOfTheModuleThatImportsAnEntry` reads that the check `registries` passes, that the definition is recorded for `importer`, and that it is not recorded for `caisson/activation-marker`. Co-Authored-By: Claude Opus 5.5 --- tests/pinned-world/default.nix | 30 +++++++++++++++++++ .../modules/nixos/importer/default.nix | 20 +++++++++++++ .../modules/nixos/importer/option.nix | 11 +++++++ 3 files changed, 61 insertions(+) create mode 100644 tests/pinned-world/registries/modules/nixos/importer/default.nix create mode 100644 tests/pinned-world/registries/modules/nixos/importer/option.nix diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 6896de9..88376d1 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -7187,6 +7187,36 @@ let in top.checks.x86_64-linux.registries.message == ""; + # The registry entry `caisson/activation-marker` of the generic + # class, from the project caisson, imports the module that caisson exports for it through a + # module that names no file and imports several modules. The module + # system gives that module the file of the module that imports the + # entry. The module `importer` imports the entry, and defines + # `caisson.importerNote` in its file. The check `registries` takes + # the files of the entry from the modules below it that name their + # file. It records the definition for `importer`, which imports the + # declaration of the option, and not for + # `caisson/activation-marker`, which does not, and it passes. + registriesIgnoresTheDefinitionsOfTheModuleThatImportsAnEntry = + let + top = registriesTopWith "importer"; + # The registry entries of the NixOS configuration `nas-1`. + entries = builtins.foldl' (found: manifest: found // manifest.registryEntries or { }) { } ( + builtins.filter (manifest: manifest.type or null == "nixos") (manifestsOf top.caisson.manifest) + ); + in + top.checks.x86_64-linux.registries.message == "" + && builtins.all ( + definition: definition.option != "caisson.importerNote" + ) entries."modules.generic.caisson/activation-marker".defines + && entries."modules.generic.caisson/activation-marker".file == "modules/generic/activation-marker" + && builtins.any ( + definition: + definition.option == "caisson.importerNote" + && definition.files == [ "tests/pinned-world/registries/modules/nixos/importer" ] + && definition.declared + ) entries."modules.nixos.importer".defines; + # The check `registries` fails to build where a registry entry # defines an option under `caisson` and imports no module that # declares it, as the module `undeclared` does. Its message names diff --git a/tests/pinned-world/registries/modules/nixos/importer/default.nix b/tests/pinned-world/registries/modules/nixos/importer/default.nix new file mode 100644 index 0000000..f7bdc4a --- /dev/null +++ b/tests/pinned-world/registries/modules/nixos/importer/default.nix @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT +# +# A NixOS module that imports the registry entry +# `caisson/activation-marker` of the generic class and defines the option +# `caisson.importerNote`, which the file `option.nix` beside this file +# declares. `caisson/activation-marker` does not import `option.nix`. +# The registry gives the wrappers of `caisson/activation-marker` the +# file of this module, and this module defines the option in the same +# file. The check `registriesIgnoresTheDefinitionsOfTheModuleThatImportsAnEntry` +# of the pinned-world suite selects this module and reads that the +# check `registries` passes. +{ closure-lib, ... }: +{ ... }: +{ + imports = [ + ./option.nix + closure-lib.caisson.modules.generic."caisson/activation-marker" + ]; + caisson.importerNote = "a note of the importer module"; +} diff --git a/tests/pinned-world/registries/modules/nixos/importer/option.nix b/tests/pinned-world/registries/modules/nixos/importer/option.nix new file mode 100644 index 0000000..fc2b178 --- /dev/null +++ b/tests/pinned-world/registries/modules/nixos/importer/option.nix @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MIT +# +# The declaration of `caisson.importerNote`, which the module +# `importer` imports and defines. +{ lib, ... }: +{ + options.caisson.importerNote = lib.mkOption { + type = lib.types.str; + description = "A note that the module `importer` defines."; + }; +} From 6d81d5e9e2111dbe8b3e56996758fbbb094ddd40 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Thu, 12 Nov 2026 00:04:13 -0800 Subject: [PATCH 3/5] fix(checks): follow the key of a module to the declarations it imports The module system evaluates the modules that share a key once, at the first place it reaches one of them, and the import graph holds each of them where it is imported. ch-personal-machines pins a revision of caisson and lists ch-facter, which pins another revision. The entry `ch-facter/default` of the class caisson-facter imports `caisson/core` from the copy of caisson that ch-facter pins, and the NixOS configuration evaluates the core module from the copy that ch-personal-machines pins, so the declaration of `caisson.forNearestChildren.extraDefaultModuleImports` is in a file of that copy. The check `registries` compared files and reported that `ch-facter/default` imports no declaration of the option. The files of an entry are now the files named by the modules of every key the entry reaches, over every place the graph holds a module of that key. Co-Authored-By: Claude Opus 5.5 --- lib-overlays/integrations/default.nix | 78 ++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/lib-overlays/integrations/default.nix b/lib-overlays/integrations/default.nix index 3d1224e..abeb8e0 100644 --- a/lib-overlays/integrations/default.nix +++ b/lib-overlays/integrations/default.nix @@ -2377,7 +2377,8 @@ # A registry entry is a module whose key is # `modules..@` (`keyedModules`). The files # of an entry are the files that the modules the entry imports, - # at any depth, name. The result holds each entry by registry + # at any depth, name, where a module stands for every module of + # the graph with its key. The result holds each entry by registry # key, with `project`, `file`, and `defines`: for each option # under `caisson` that a file of the entry defines, the option # as `option`, the files of the entry that define it as `files`, @@ -2427,13 +2428,49 @@ ) ++ namedBelow imported ) (enabled node.imports); - entriesIn = - nodes: + # Every module of the graph, depth first, as `node`, with the + # file of the module that imports it as `importer`, null for + # a module that `lib.evalModules` was given. + occurrencesIn = + importer: nodes: builtins.concatMap ( - node: - (if builtins.substring 0 8 (toString (node.key or "")) == "modules." then [ node ] else [ ]) - ++ entriesIn (enabled node.imports) + node: [ { inherit node importer; } ] ++ occurrencesIn node.file (enabled node.imports) ) (enabled nodes); + occurrences = occurrencesIn null graph; + # The key of a module as an attribute name. The key of a + # module read from a file is the path of the file, a string + # that refers to a store path, and an attribute name may not + # refer to one. + keyOf = node: builtins.unsafeDiscardStringContext (toString node.key); + # The modules of the graph by key, each with `files`, the + # files that the modules of the key name, and `imports`, the + # keys of the modules they import, over every place the graph + # holds a module of the key. + # + # The module system evaluates the modules that share a key + # once, at the first place it reaches one of them, and the + # graph holds each of them where it is imported. The copies + # of a project in different store paths give a module the + # same key and different files: a flake that pins a revision + # of caisson and lists a project that pins another revision + # imports the core module of caisson from each copy. The + # declarations of the options of the + # core module are then in the files of the copy the module + # system reached first. A module that imports the other copy + # imports those declarations through the key they share. + byKey = builtins.mapAttrs (_: found: { + files = unique ( + builtins.concatMap ( + occurrence: if occurrence.node.file != occurrence.importer then [ occurrence.node.file ] else [ ] + ) found + ); + imports = unique ( + builtins.concatMap (occurrence: builtins.map keyOf (enabled occurrence.node.imports)) found + ); + }) (builtins.groupBy (occurrence: keyOf occurrence.node) occurrences); + entries = builtins.filter (node: builtins.substring 0 8 (toString (node.key or "")) == "modules.") ( + builtins.map (occurrence: occurrence.node) occurrences + ); entryOf = node: let @@ -2444,11 +2481,17 @@ # wrappers carry the file of that configuration module, # and the definitions that the configuration module makes # carry the same file. The files of the entry are therefore - # the files that the modules below the entry name - # (`namedBelow`), and a definition belongs to the entry - # only when one of these files makes it. The file shown - # for the entry is the first of these files whose module + # the files that the modules below the entry name, and a + # definition belongs to the entry only when one of these + # files makes it. The modules below the entry are those of + # the keys the entry reaches through the imports of each + # key (`byKey`). The file shown for the entry is the first + # file named below the entry (`namedBelow`) whose module # does more than wrap a single module that names its file. + reached = builtins.genericClosure { + startSet = [ { key = keyOf node; } ]; + operator = found: builtins.map (key: { inherit key; }) byKey.${found.key}.imports; + }; named = namedBelow node; shown = builtins.filter (module: !module.wraps) named; file = @@ -2458,8 +2501,17 @@ (builtins.head named).file else node.file; - files = unique (builtins.map (module: module.file) named); - inEntry = file: builtins.elem file files; + # The files of the entry as attribute names. + files = builtins.listToAttrs ( + builtins.concatMap ( + found: + builtins.map (file: { + name = builtins.unsafeDiscardStringContext (toString file); + value = true; + }) byKey.${found.key}.files + ) reached + ); + inEntry = file: files ? ${builtins.unsafeDiscardStringContext (toString file)}; defined = builtins.filter (option: builtins.any inEntry option.files) caissonOptions; registered = registryPartsOf node.key; in @@ -2477,7 +2529,7 @@ }; }; in - builtins.listToAttrs (builtins.map entryOf (entriesIn graph)); + builtins.listToAttrs (builtins.map entryOf entries); # The check `registries` of a caisson top for `system`, by name, # the default check every caisson top carries. From d6b246cb5b940029ab1095a647f2f6b28a93ae28 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Thu, 12 Nov 2026 00:04:13 -0800 Subject: [PATCH 4/5] test(checks): check registries against a copy of the core module under its key The module `copied-core` of tests/pinned-world/registries imports a module at another file under the key of the core module of caisson for the nixos class, and defines `caisson.structural.exportNote`. The check `registriesFollowsTheKeyOfAModuleToItsDeclarations` reads that the check `registries` passes and records the definition as declared. Co-Authored-By: Claude Opus 5.5 --- tests/pinned-world/default.nix | 21 +++++++++++++ .../modules/nixos/copied-core/copy.nix | 7 +++++ .../modules/nixos/copied-core/default.nix | 31 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/pinned-world/registries/modules/nixos/copied-core/copy.nix create mode 100644 tests/pinned-world/registries/modules/nixos/copied-core/default.nix diff --git a/tests/pinned-world/default.nix b/tests/pinned-world/default.nix index 88376d1..aa05d59 100644 --- a/tests/pinned-world/default.nix +++ b/tests/pinned-world/default.nix @@ -7217,6 +7217,27 @@ let && definition.declared ) entries."modules.nixos.importer".defines; + # The module system evaluates the modules that share a key once, + # so the declarations of the options of the core module of caisson + # are in the files of the copy of the core module it reaches + # first. The module `copied-core` imports a module at another file + # under the key of the core module, as a project that pins another + # revision of caisson does, and defines an option that the core + # module declares. The check `registries` follows the key to the + # files of the core module that caisson imports, and passes. + registriesFollowsTheKeyOfAModuleToItsDeclarations = + let + top = registriesTopWith "copied-core"; + # The registry entries of the NixOS configuration `nas-1`. + entries = builtins.foldl' (found: manifest: found // manifest.registryEntries or { }) { } ( + builtins.filter (manifest: manifest.type or null == "nixos") (manifestsOf top.caisson.manifest) + ); + in + top.checks.x86_64-linux.registries.message == "" + && builtins.any ( + definition: definition.option == "caisson.structural.exportNote" && definition.declared + ) entries."modules.nixos.copied-core".defines; + # The check `registries` fails to build where a registry entry # defines an option under `caisson` and imports no module that # declares it, as the module `undeclared` does. Its message names diff --git a/tests/pinned-world/registries/modules/nixos/copied-core/copy.nix b/tests/pinned-world/registries/modules/nixos/copied-core/copy.nix new file mode 100644 index 0000000..8d1b2de --- /dev/null +++ b/tests/pinned-world/registries/modules/nixos/copied-core/copy.nix @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: MIT +# +# The stand-in for the core module of caisson in another copy of +# caisson, which the module `copied-core` imports under the key of the +# core module. +{ ... }: +{ } diff --git a/tests/pinned-world/registries/modules/nixos/copied-core/default.nix b/tests/pinned-world/registries/modules/nixos/copied-core/default.nix new file mode 100644 index 0000000..fd62278 --- /dev/null +++ b/tests/pinned-world/registries/modules/nixos/copied-core/default.nix @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: MIT +# +# A NixOS module that defines an option under `caisson` and imports a +# second copy of the core module of caisson for the nixos class. A +# project that pins another revision of caisson imports the core +# module from that revision: the module has the key that the registry +# gives the core module, and its files are in another store path. Here +# the module `copy.nix` beside this file stands for that copy, under +# the key of the module that the registry entry `caisson/core` imports. +# The module system evaluates the modules that share a key once, and +# caisson imports the core module into the NixOS configuration first, +# so `copy.nix` is not evaluated. The check +# `registriesFollowsTheKeyOfAModuleToItsDeclarations` of the +# pinned-world suite selects this module and reads that the check +# `registries` passes. +{ closure-lib, ... }: +{ ... }: +let + # The module that the registry entry `caisson/core` imports, keyed + # `registered.modules.nixos.core@caisson`. + registered = builtins.head closure-lib.caisson.modules.nixos."caisson/core".imports; +in +{ + imports = [ + { + inherit (registered) key; + imports = [ ./copy.nix ]; + } + ]; + caisson.structural.exportNote = "a note of the module that imports a copy of the core module"; +} From ff3f822f93818d9acac419bf37ca84a553e46a8e Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Thu, 12 Nov 2026 00:32:20 -0800 Subject: [PATCH 5/5] test(checks): publish the check registries of each test flake under its name Each test flake that is a caisson top has the check `registries`, and the caisson top of caisson publishes the check `registries` of caisson under that name. The checks configuration publishes the check of a test flake as `-registries`, such as `nixpkgs-overlay-export-registries`, so `nix flake check` builds it. Co-Authored-By: Claude Opus 5.5 --- configs/flake/checks/default.nix | 41 +++++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/configs/flake/checks/default.nix b/configs/flake/checks/default.nix index de8ba58..c19d893 100644 --- a/configs/flake/checks/default.nix +++ b/configs/flake/checks/default.nix @@ -190,26 +190,33 @@ in } ''; - # The checks of a test flake for this system, without its check - # `registries`. A test flake that is a caisson top has the check - # `registries`, and the caisson top of caisson publishes the check - # `registries` of caisson under that name. - testChecksOf = outputs: builtins.removeAttrs outputs.checks.${system} [ "registries" ]; + # The checks of the test flake `name` for this system. A test + # flake that is a caisson top has the check `registries`, and the + # caisson top of caisson publishes the check `registries` of + # caisson under that name, so the check `registries` of the test + # flake is published as `-registries`. + testChecksOf = + name: outputs: + let + checks = outputs.checks.${system}; + in + builtins.removeAttrs checks [ "registries" ] + // (if checks ? registries then { "${name}-registries" = checks.registries; } else { }); # The checks for x86_64-linux. linuxChecks = - testChecksOf integrationOutputs - // testChecksOf minimalConsumerOutputs - // testChecksOf moduleClassExportOutputs - // testChecksOf libConsumerChainFinalOutputs - // testChecksOf nixpkgsConsumerOutputs - // testChecksOf nixpkgsOverlayExportOutputs - // testChecksOf nixpkgsNoPkgSetsOutputs - // testChecksOf nixpkgsPkgSetsOutputs - // testChecksOf nixpkgsLibOnlyOutputs - // testChecksOf pkgOverlaysProducerOutputs - // testChecksOf pkgOverlaysConsumerOutputs - // testChecksOf unitTestOutputs + testChecksOf "basic-composition" integrationOutputs + // testChecksOf "minimal-consumer" minimalConsumerOutputs + // testChecksOf "module-class-export" moduleClassExportOutputs + // testChecksOf "lib-consumer-chain" libConsumerChainFinalOutputs + // testChecksOf "nixpkgs-consumer" nixpkgsConsumerOutputs + // testChecksOf "nixpkgs-overlay-export" nixpkgsOverlayExportOutputs + // testChecksOf "nixpkgs-no-pkg-sets" nixpkgsNoPkgSetsOutputs + // testChecksOf "nixpkgs-pkg-sets" nixpkgsPkgSetsOutputs + // testChecksOf "nixpkgs-lib-only" nixpkgsLibOnlyOutputs + // testChecksOf "pkg-overlays-registry-producer" pkgOverlaysProducerOutputs + // testChecksOf "pkg-overlays-registry-consumer" pkgOverlaysConsumerOutputs + // testChecksOf "unit" unitTestOutputs // { literate-flake-default = exampleOutputs.packages.${system}.default; literate-flake-greeting = exampleOutputs.packages.${system}.greeting;