Modular, context-aware and aspect-oriented dendritic Nix configurations. Discussions: https://oeiuwq.zulipchat.com/join/nqp26cd4kngon6mo3ncgnuap/ den.oeiuwq.com
configurations den dendritic nix aspect oriented
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: module-level dedup for host-aspects overlap support (#468)

Named aspects now get a key attribute (class@identity) on their
collected modules. The NixOS module system deduplicates by key across
independent resolve calls, so a user can include an aspect directly AND
receive it via host-aspects without duplicate option declarations.

Anonymous/synthetic aspects are excluded from keying so multiple
anonymous includes coexist correctly.

Also fixes the pre-existing duplication where aspects included by both
host and user produced duplicate nixos modules via the default context
transition.

authored by

Jason Bowman and committed by
GitHub
815076ec df2f95ce

+344 -27
+2 -8
modules/aspects/provides/host-aspects.nix
··· 4 4 5 5 description = '' 6 6 Projects all homeManager-class configs from the host's aspect tree 7 - onto users who opt in. 7 + onto users who opt in. Requires the fx pipeline. 8 8 9 9 ## Usage 10 10 ··· 13 13 Any host aspect that defines a `homeManager` key will have that 14 14 config forwarded to the user's homeManager evaluation. Other class 15 15 keys (nixos, darwin) are ignored — host.aspect is resolved 16 - specifically for class "homeManager", so only homeManager modules 17 - are collected. This avoids duplicating nixos modules that are 18 - already applied via the host's own resolution. 16 + specifically for class "homeManager". 19 17 ''; 20 18 21 - # Resolve host.aspect for homeManager class only, producing a single 22 - # homeManager module. This prevents nixos/darwin class keys from 23 - # being collected again when the user context contributes to the 24 - # host's resolution. 25 19 from-host = 26 20 { host, user }: 27 21 {
+21 -7
nix/lib/aspects/fx/aspect.nix
··· 56 56 in 57 57 fx.seq (map (c: fx.send "register-constraint" (c // { inherit owner; })) allConstraints); 58 58 59 - # Fold includes through emit-include effects. 59 + # Fold includes through emit-include effects, tagging each with its 60 + # positional index so the handler can derive stable identities for 61 + # anonymous includes (parentIdentity/<anon>:index). 60 62 emitIncludes = 61 63 incs: 62 - builtins.foldl' ( 63 - acc: child: 64 - fx.bind acc ( 65 - results: fx.bind (fx.send "emit-include" child) (childResults: fx.pure (results ++ childResults)) 66 - ) 67 - ) (fx.pure [ ]) incs; 64 + let 65 + len = builtins.length incs; 66 + go = 67 + idx: acc: 68 + if idx >= len then 69 + acc 70 + else 71 + go (idx + 1) ( 72 + fx.bind acc ( 73 + results: 74 + fx.bind (fx.send "emit-include" { 75 + child = builtins.elemAt incs idx; 76 + inherit idx; 77 + }) (childResults: fx.pure (results ++ childResults)) 78 + ) 79 + ); 80 + in 81 + go 0 (fx.pure [ ]); 68 82 69 83 # Emit into-transition effects for each key in aspect.into. 70 84 # into is a function ctx → attrset. We pass the unevaluated function
+22 -2
nix/lib/aspects/fx/handlers/include.nix
··· 131 131 # Keep: resolve via aspectToEffect (which emits resolve-complete internally). 132 132 keepChild = child: fx.bind (aspectToEffect child) (resolved: fx.pure [ resolved ]); 133 133 134 - # The handler. 134 + # Derive a stable name for anonymous aspects from parent chain + index. 135 + nameAnon = 136 + state: idx: 137 + let 138 + chain = state.includesChain or [ ]; 139 + parent = if chain == [ ] then "<root>" else lib.last chain; 140 + in 141 + "${parent}/<anon>:${toString idx}"; 142 + 143 + isMeaningfulName = 144 + name: name != "<anon>" && name != "<function body>" && !(lib.hasPrefix "[definition " name); 145 + 146 + # The handler. param is { child, idx } from emitIncludes. 135 147 includeHandler = { 136 148 "emit-include" = 137 149 { param, state }: 138 150 let 139 - child = wrapChild param; 151 + rawChild = param.child or param; 152 + idx = param.idx or null; 153 + wrapped = wrapChild rawChild; 154 + # Replace anonymous names with parent+index derived identity. 155 + child = 156 + if idx != null && !(isMeaningfulName (wrapped.name or "<anon>")) then 157 + wrapped // { name = nameAnon state idx; } 158 + else 159 + wrapped; 140 160 childIdentity = identity.pathKey (identity.aspectPath child); 141 161 isConditional = builtins.isAttrs child && child ? meta && child.meta ? guard; 142 162 in
+21 -1
nix/lib/aspects/fx/handlers/tree.nix
··· 145 145 else 146 146 let 147 147 identity = param.identity or "<anon>"; 148 - mod = lib.setDefaultModuleLocation "${param.class}@${identity}" param.module; 148 + loc = "${param.class}@${identity}"; 149 + # Named aspects get a key for NixOS module-level dedup: two 150 + # resolve calls emitting the same aspect:class produce the 151 + # same key, so the module system keeps only the first. 152 + # Anonymous/synthetic names must not be keyed — multiple 153 + # anonymous includes with the same identity are distinct. 154 + isAnon = 155 + identity == "<anon>" 156 + || identity == "<function body>" 157 + || lib.hasPrefix "[definition " identity 158 + || lib.hasPrefix "<root>/" identity 159 + || lib.hasInfix "/<anon>:" identity; 160 + mod = 161 + if isAnon then 162 + lib.setDefaultModuleLocation loc param.module 163 + else 164 + { 165 + key = loc; 166 + _file = loc; 167 + imports = [ param.module ]; 168 + }; 149 169 in 150 170 { 151 171 resume = null;
+26 -8
nix/lib/forward.nix
··· 6 6 guard ? null, 7 7 adaptArgs ? null, 8 8 adapterModule ? null, 9 + evalConfig ? false, 9 10 ... 10 11 }@fwd: 11 12 let ··· 24 25 25 26 forward = 26 27 path: 27 - let 28 - value = lib.setAttrByPath path (_: { 29 - imports = [ sourceModule ]; 30 - }); 31 - in 32 - { 33 - ${intoClass} = value; 34 - }; 28 + if evalConfig then 29 + # Evaluate source module in a freeform submodule and set the 30 + # resulting config at the target path. Use for leaf option 31 + # targets (e.g. environment.sessionVariables) that can't accept 32 + # module functions as values. 33 + let 34 + evaluated = lib.evalModules { 35 + modules = [ 36 + freeformMod 37 + sourceModule 38 + ]; 39 + }; 40 + in 41 + { 42 + ${intoClass} = lib.setAttrByPath path (builtins.removeAttrs evaluated.config [ "_module" ]); 43 + } 44 + else 45 + let 46 + value = lib.setAttrByPath path (_: { 47 + imports = [ sourceModule ]; 48 + }); 49 + in 50 + { 51 + ${intoClass} = value; 52 + }; 35 53 36 54 freeformMod = { 37 55 config._module.freeformType = lib.types.lazyAttrsOf lib.types.unspecified;
+90
templates/ci/modules/features/debug-fwd.nix
··· 1 + # Forward custom class to leaf option using evalConfig. 2 + { denTest, ... }: 3 + { 4 + flake.tests.fwd-leaf-option = { 5 + 6 + # Forward a custom "variables" class to environment.sessionVariables. 7 + test-fwd-variables-static = denTest ( 8 + { 9 + den, 10 + lib, 11 + igloo, 12 + ... 13 + }: 14 + { 15 + den.hosts.x86_64-linux.igloo.users.tux = { }; 16 + 17 + den.aspects.igloo.variables.TEST = "test-var"; 18 + 19 + den.ctx.host.includes = [ 20 + ( 21 + { host, ... }: 22 + den._.forward { 23 + each = [ "nixos" ]; 24 + fromClass = _: "variables"; 25 + intoClass = _: host.class; 26 + intoPath = _: [ 27 + "environment" 28 + "sessionVariables" 29 + ]; 30 + fromAspect = _: host.aspect; 31 + evalConfig = true; 32 + } 33 + ) 34 + ]; 35 + 36 + expr = igloo.environment.sessionVariables.TEST; 37 + expected = "test-var"; 38 + } 39 + ); 40 + 41 + # perHost parametric children with evalConfig. 42 + test-fwd-perHost-variables = denTest ( 43 + { 44 + den, 45 + lib, 46 + igloo, 47 + ... 48 + }: 49 + { 50 + den.hosts.x86_64-linux.igloo.users.tux = { }; 51 + 52 + imports = [ 53 + { den.aspects.foo.includes = lib.attrValues den.aspects.foo._; } 54 + { 55 + den.ctx.host.includes = [ 56 + ( 57 + { host, ... }: 58 + den._.forward { 59 + each = [ "nixos" ]; 60 + fromClass = _: "variables"; 61 + intoClass = _: host.class; 62 + intoPath = _: [ 63 + "environment" 64 + "sessionVariables" 65 + ]; 66 + fromAspect = _: den.lib.parametric.fixedTo { inherit host; } host.aspect; 67 + evalConfig = true; 68 + } 69 + ) 70 + ]; 71 + } 72 + { den.aspects.foo._.sub1 = den.lib.perHost { variables.TEST = "test-var"; }; } 73 + { den.aspects.foo._.sub2 = den.lib.perHost { variables.OTHER = "other-var"; }; } 74 + ]; 75 + 76 + den.aspects.igloo.includes = [ den.aspects.foo ]; 77 + 78 + expr = { 79 + test = igloo.environment.sessionVariables.TEST; 80 + other = igloo.environment.sessionVariables.OTHER; 81 + }; 82 + expected = { 83 + test = "test-var"; 84 + other = "other-var"; 85 + }; 86 + } 87 + ); 88 + 89 + }; 90 + }
+2 -1
templates/ci/modules/features/fx-aspect.nix
··· 20 20 { param, state }: 21 21 { 22 22 # For these tests, just return the child as-is (no recursive resolution). 23 - resume = [ param ]; 23 + # emitIncludes sends { child, idx } — extract the child. 24 + resume = [ (param.child or param) ]; 24 25 inherit state; 25 26 }; 26 27 "register-constraint" =
+160
templates/ci/modules/features/host-aspects.nix
··· 138 138 } 139 139 ); 140 140 141 + # Host sub-aspects with homeManager project through includes. 142 + # Shared aspects (included by both host and user) must not cause 143 + # duplicate module conflicts. 144 + test-shared-sub-aspects-no-duplication = denTest ( 145 + { 146 + den, 147 + lib, 148 + igloo, 149 + tuxHm, 150 + ... 151 + }: 152 + { 153 + den.hosts.x86_64-linux.igloo.users.tux = { }; 154 + 155 + den.default.nixos.imports = [ 156 + { 157 + options.tags = lib.mkOption { 158 + type = lib.types.listOf lib.types.str; 159 + default = [ ]; 160 + }; 161 + } 162 + ]; 163 + den.default.homeManager.imports = [ 164 + { 165 + options.hm-tags = lib.mkOption { 166 + type = lib.types.listOf lib.types.str; 167 + default = [ ]; 168 + }; 169 + } 170 + ]; 171 + 172 + # Shared aspect: included by BOTH host and user directly. 173 + den.aspects.shared-tools = { 174 + nixos.tags = [ "shared" ]; 175 + homeManager.hm-tags = [ "shared" ]; 176 + }; 177 + 178 + # Host-only aspect with homeManager config. 179 + den.aspects.host-desktop = { 180 + nixos.tags = [ "desktop" ]; 181 + homeManager.hm-tags = [ "desktop" ]; 182 + }; 183 + 184 + den.aspects.igloo = { 185 + nixos.tags = [ "host" ]; 186 + includes = [ 187 + den.aspects.shared-tools 188 + den.aspects.host-desktop 189 + ]; 190 + }; 191 + 192 + den.aspects.tux = { 193 + includes = [ 194 + den.aspects.shared-tools # also included by host 195 + den._.host-aspects 196 + ]; 197 + homeManager.hm-tags = [ "user" ]; 198 + }; 199 + 200 + expr = { 201 + # nixos tags: host + shared + desktop (each exactly once) 202 + nixosTags = lib.sort (a: b: a < b) igloo.tags; 203 + # shared appears via both direct user include AND host-aspects — must not conflict 204 + # hm tags: user's own + shared (direct) + desktop (via host-aspects) + shared (via host-aspects) 205 + # shared appears via both direct include and host-aspects — must not conflict 206 + hmHasDesktop = builtins.elem "desktop" tuxHm.hm-tags; 207 + hmHasUser = builtins.elem "user" tuxHm.hm-tags; 208 + hmHasShared = builtins.elem "shared" tuxHm.hm-tags; 209 + }; 210 + expected = { 211 + nixosTags = [ 212 + "desktop" 213 + "host" 214 + "shared" 215 + ]; 216 + hmHasDesktop = true; 217 + hmHasUser = true; 218 + hmHasShared = true; 219 + }; 220 + } 221 + ); 222 + 223 + # Overlap: user includes an aspect directly AND it appears in host tree 224 + # via host-aspects. Module dedup prevents duplicate option declarations. 225 + test-overlap-no-conflict = denTest ( 226 + { 227 + den, 228 + lib, 229 + tuxHm, 230 + ... 231 + }: 232 + { 233 + den.hosts.x86_64-linux.igloo.users.tux = { }; 234 + 235 + den.default.homeManager.imports = [ 236 + { 237 + options.hm-tags = lib.mkOption { 238 + type = lib.types.listOf lib.types.str; 239 + default = [ ]; 240 + }; 241 + } 242 + ]; 243 + 244 + den.aspects.shared-tool = { 245 + homeManager.hm-tags = [ "shared" ]; 246 + }; 247 + 248 + den.aspects.igloo.includes = [ den.aspects.shared-tool ]; 249 + 250 + # User includes shared-tool directly AND via host-aspects (overlap). 251 + den.aspects.tux.includes = [ 252 + den.aspects.shared-tool 253 + den._.host-aspects 254 + ]; 255 + 256 + expr = tuxHm.hm-tags; 257 + expected = [ "shared" ]; 258 + } 259 + ); 260 + 261 + # Multiple users each get distinct homeManager modules from a named 262 + # host sub-aspect that uses the user arg. The key dedup must not 263 + # collapse modules across users — each user's resolve call has its 264 + # own key namespace. 265 + test-multi-user-distinct = denTest ( 266 + { 267 + den, 268 + lib, 269 + tuxHm, 270 + pinguHm, 271 + ... 272 + }: 273 + { 274 + den.hosts.x86_64-linux.igloo.users = { 275 + tux = { }; 276 + pingu = { }; 277 + }; 278 + 279 + den.aspects.user-greeting = 280 + { user, ... }: 281 + { 282 + homeManager.home.sessionVariables.GREETING = "hello-${user.name}"; 283 + }; 284 + 285 + den.aspects.igloo.includes = [ den.aspects.user-greeting ]; 286 + 287 + den.aspects.tux.includes = [ den._.host-aspects ]; 288 + den.aspects.pingu.includes = [ den._.host-aspects ]; 289 + 290 + expr = { 291 + tux = tuxHm.home.sessionVariables.GREETING; 292 + pingu = pinguHm.home.sessionVariables.GREETING; 293 + }; 294 + expected = { 295 + tux = "hello-tux"; 296 + pingu = "hello-pingu"; 297 + }; 298 + } 299 + ); 300 + 141 301 # User who does NOT include den._.host-aspects does not receive host homeManager. 142 302 test-opt-in-only = denTest ( 143 303 {