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.

feat: `entity.hasAspect <ref>` query method on context entities (#439)

# feat: `entity.hasAspect <ref>` query method

## What this does

Adds a `.hasAspect` method on context entities (`host`, `user`, `home`,
and any custom entity kind that imports `den.schema.conf`). It answers
"is this aspect structurally present in my resolved tree?" from inside
class-config module bodies:

```nix
den.aspects.impermanence.nixos = { config, host, ... }: lib.mkMerge [
(lib.mkIf (host.hasAspect <zfs-root>) { /* zfs impermanence */ })
(lib.mkIf (host.hasAspect <btrfs-root>) { /* btrfs impermanence */ })
];
```

There are two variants for when the bare form isn't enough:

```nix
host.hasAspect.forClass "nixos" <facter>
user.hasAspect.forAnyClass <agenix-rekey>
```

Identity is compared by `aspectPath` (`meta.provider ++ [name]`), so
provider sub-aspects like `foo._.sub` keep their full path. Refs can be
plain aspect values (`den.aspects.facter`) or `<angle-bracket>` sugar,
they're the same thing after `__findFile` resolves.

Alongside `hasAspect`, this ships a companion `oneOfAspects` adapter.
That's the structural-decision primitive for "prefer A over B when both
are present", which is the thing you actually want when you're tempted
to use `hasAspect` to decide includes (see the guardrails section
below).

## Why

Real patterns that users hit:

- `<impermanence>` config depends on whether `<zfs-root>` or
`<btrfs-root>` is also configured on the host
- A secrets forward wants to pick `<agenix-rekey>` when present and
fall back to `<sops-nix>` otherwise
- Library aspects want to gate opt-in behavior on companion aspects

Today these get worked around with `config.*` lookups, hand-maintained
`lib.elem` checks, or structural hacks. `hasAspect` is the first-class
primitive for the read side. `oneOfAspects` is the first-class primitive
for the write side.

## Commits

Each commit is independently reviewable and builds green on its own.

### `fix(parametric): preserve meta on materialized parametric results`

Opened as it's own PR #440

### `feat(adapters): add collectPaths terminal adapter`

New public terminal adapter. Walks a resolved tree via `filterIncludes`
and returns `{ paths = [ [providerSeg..., name], ... ]; }`, depth-first,
not deduplicated. Tombstones are skipped via the `meta.excluded` check.

Ships with two small helpers exported from the same file: `pathKey`
(slash-joined path key) and `toPathSet` (list of paths to attrset-as-set
for O(1) lookups). Both are used by `hasAspect` and `oneOfAspects`, so
exporting them keeps the two consumers from duplicating the same
one-liners.

### `feat(adapters): add oneOfAspects structural-decision adapter`

`meta.adapter` that keeps the first structurally-present candidate and
tombstones the rest via `excludeAspect`:

```nix
den.aspects.secrets-bundle = {
includes = [ <agenix-rekey> <sops-nix> ];
meta.adapter = den.lib.aspects.adapters.oneOfAspects [
<agenix-rekey> # preferred
<sops-nix> # fallback
];
};
```

Complements `excludeAspect` and `substituteAspect`. The three together
cover include-this / exclude-this / swap-this-for-that. Internally it
walks the parent subtree with the raw collector (bypassing
`filterIncludes` so it doesn't re-enter itself), finds which candidates
are present, and folds `excludeAspect` over the losers. No code
duplication with `collectPaths` thanks to the shared helpers from the
previous commit.

### `feat(aspects): add has-aspect library primitives`

New file `nix/lib/aspects/has-aspect.nix` exporting:

- `hasAspectIn { tree; class; ref }` for any resolved tree, not just
entity contexts
- `collectPathSet { tree; class }` for an attrset-as-set of visible
paths
- `mkEntityHasAspect { tree; primaryClass; classes }` which builds the
functor-plus-attrs value attached to entities. Per-class path sets
are thunk-cached, so repeated calls share one traversal per class

`refKey` validates that its input has both `name` and `meta` before
reaching for `aspectPath`, throwing loudly rather than silently
producing an `<anon>` path key.

### `feat(context): add entity.hasAspect method via den.schema.conf`

The wiring. `modules/context/has-aspect.nix` is a flake-level module
that self-wires into `den.schema.conf` via
`config.den.schema.conf.imports`. Every entity type that imports `conf`
(host, user, home, and any user-defined kind) inherits `.hasAspect`
automatically. Zero changes to `nix/lib/types.nix`.

Class-protocol: prefers `classes` (list), falls back to `[ class ]`,
throws otherwise. Entities without a matching `den.ctx.<kind>` (so no
`config.resolved`) produce a call-time throw. The fallback value
preserves the functor-plus-attrs shape so `forClass` / `forAnyClass`
attribute access doesn't leak a cryptic error before reaching the real
one.

### `test(has-aspect): full regression-class coverage for entity method`

31 tests organized by aspect-construction shape. Every shape that's
produced a recent regression (`#408`, `#413`, `#423`, `#429`) has a
lock-in test. A future regression in `parametric.nix` or
`aspects/types.nix` that breaks any of these shapes trips a `has-aspect`
test before it reaches user code.

Groups cover basic and transitive chains, parametric contexts including
the static and bare-function sub-aspect shapes, factory functions,
provider sub-aspects and identity disambiguation, mutual-provider and
provides chains, `meta.adapter` composition, multi-class users, the
extensibility contract, error cases, and the primary intended use case
(calling `hasAspect` from inside a deferred `nixos` module body).

### `docs(example): add hasAspect + oneOfAspects worked examples`

User-facing pedagogical file in `templates/example/`. Three sections:
reading structure via `host.hasAspect` from a class-config body, writing
structure via `oneOfAspects` as a `meta.adapter`, and an anti-pattern
section explaining why `hasAspect` can't decide an aspect's `includes`
list with a pointer at the adapter library.

## Design guardrails

`hasAspect` is a read-only query on frozen structure. You call it from
inside class-config module bodies (`nixos = ...`, `homeManager = ...`)
or from lazy positions in aspect functor bodies. It's cycle-safe by
construction because by the time deferred class modules evaluate, the
aspect tree has already been resolved and frozen.

What it is not for: deciding an aspect's `includes` list. That's cyclic.
The tree you want to query depends on the decision you want `hasAspect`
to inform. Users who need that reach for `meta.adapter` composed via
`oneOfAspects`, `excludeAspect`, `substituteAspect`, or `filter` /
`filterIncludes`. Those run during the tree walk with full structural
visibility, so they can't cycle. The template example file has an
explicit anti-pattern section with the failing shape and the correct
rewrite.

Two tools, two jobs:

| Need | Tool | When it runs |
|---|---|---|
| Read "is X in my tree?" from module config | `hasAspect` | After the
tree is frozen, inside lazy class-module bodies |
| Decide tree structure based on "is X present?" | `meta.adapter` +
`oneOfAspects` / friends | During the tree walk, with full structural
visibility |

## Test plan

- [x] `just ci` passes 331/331 at branch tip
- [x] Each commit builds and passes tests on its own
- [x] `just fmt` is idempotent across the tree
- [x] The parametric fix doesn't regress `deadbugs/issue-413-*`,
`deadbugs/issue-423-*`, or `issue-408` tests
- [x] Full regression-class matrix covers every aspect shape that's
produced a recent bug

## Migration

None. Purely additive.

- `hasAspect` is a new option name, no conflict.
- No signature changes in `parametric.nix`, `resolve.nix`,
`ctx-apply.nix`, `types.nix`, or the other core library files.
- `adapters.nix` gains new exports (`collectPaths`, `oneOfAspects`,
`pathKey`, `toPathSet`). Nothing is renamed or removed.
- `nix/lib/aspects/default.nix` re-exports the new lib functions under
`den.lib.aspects.*` at their canonical paths.
- `modules/context/has-aspect.nix` is a new file, picked up by the
existing `import-tree` flake setup.
- Zero changes to `nix/lib/types.nix`.

## Follow-ups

- Docs pass on when to make aspects non-parametric. Users often reach
for `perHost` / `perUser` wrappers when the aspect has no actual ctx
dependence, which makes structural tooling less reliable than it could
be. Docs-only, separate PR.
- Conditional-include wrapper `onlyIf guard target`. A small adapter
sitting next to `oneOfAspects` that lets users write `includes = [
(onlyIf <zfs-root> <zfs-impermanence>) ]` for "include target iff guard
is structurally present." Same cycle-avoidance trick (decision runs in
the wrapper's own meta.adapter, walks the subtree with the raw
`collectPathsInner` collector so it doesn't re-enter itself). Reuses
every helper this PR exports. Rough spec is written up, scoped as its
own follow-up PR.

authored by

Jason Bowman and committed by
GitHub
326ed590 2c16b8ce

+1584 -1
+72
modules/context/has-aspect.nix
··· 1 + # Defines `hasAspect` on every entity that imports den.schema.conf 2 + # (host, user, home, and user-defined kinds). Flake-level module: 3 + # the outer closure captures config.den so the inner entity submodule 4 + # can reach den.lib.aspects.mkEntityHasAspect at entity-eval time. 5 + { lib, config, ... }: 6 + let 7 + inherit (config) den; 8 + 9 + entityModule = 10 + { config, ... }: 11 + { 12 + options.hasAspect = lib.mkOption { 13 + description = '' 14 + Query whether an aspect is structurally present in this entity's 15 + resolved aspect tree. 16 + 17 + Usage: 18 + host.hasAspect <facter> # primary class 19 + host.hasAspect.forClass "nixos" <facter> # explicit class 20 + host.hasAspect.forAnyClass <facter> # union across classes 21 + 22 + Safe to call from inside class-config module bodies 23 + (`nixos = ...`, `homeManager = ...`) and from lazy positions 24 + inside aspect functor bodies. NOT safe to use for deciding an 25 + aspect's `includes` list — that's cyclic; use meta.adapter + 26 + excludeAspect / oneOfAspects for structural decisions instead. 27 + ''; 28 + internal = true; 29 + visible = false; 30 + readOnly = true; 31 + type = lib.types.raw; 32 + defaultText = lib.literalMD "Computed from `config.resolved` and the entity's class/classes."; 33 + default = 34 + let 35 + # Prefer `classes` (list), fall back to `[class]`, else error. 36 + classes = 37 + config.classes or ( 38 + if config ? class then 39 + [ config.class ] 40 + else 41 + throw "den.schema.conf.hasAspect: entity has no `class` or `classes`" 42 + ); 43 + primaryClass = 44 + if classes == [ ] then 45 + throw "den.schema.conf.hasAspect: entity has empty `classes` list" 46 + else 47 + lib.head classes; 48 + # Lazy thunk throwing at call time (not attribute access), so 49 + # entity.hasAspect / .forClass / .forAnyClass can be referenced 50 + # safely by tooling and only fire when actually invoked. 51 + err = throw ( 52 + "hasAspect: ${config.name or "<unnamed entity>"} has no config.resolved " 53 + + "(no matching den.ctx.<kind> defined)." 54 + ); 55 + in 56 + if config ? resolved then 57 + den.lib.aspects.mkEntityHasAspect { 58 + tree = config.resolved; 59 + inherit primaryClass classes; 60 + } 61 + else 62 + { 63 + __functor = _: _: err; 64 + forClass = _: _: err; 65 + forAnyClass = _: err; 66 + }; 67 + }; 68 + }; 69 + in 70 + { 71 + config.den.schema.conf.imports = [ entityModule ]; 72 + }
+59 -1
nix/lib/aspects/adapters.nix
··· 5 5 # 6 6 # See resolve.nix for the arguments passed to adapters: 7 7 # { aspect, class, classModule, recurse, aspect-chain, resolveChild } 8 - { lib, ... }: 8 + { den, lib, ... }: 9 9 let 10 10 11 11 # Produces a single module importing all classModules from aspect and its includes. ··· 158 158 159 159 default = filterIncludes module; 160 160 161 + # Slash-joined key for an aspectPath. Canonical format for path 162 + # lookup sets. 163 + pathKey = path: lib.concatStringsSep "/" path; 164 + 165 + # Convert a list of aspectPaths into an attrset-as-set keyed by pathKey. 166 + toPathSet = 167 + paths: 168 + builtins.listToAttrs ( 169 + builtins.map (p: { 170 + name = pathKey p; 171 + value = true; 172 + }) paths 173 + ); 174 + 175 + # Shared walker used by collectPaths (through filterIncludes, so it 176 + # sees tombstones) and by oneOfAspects (raw, to avoid re-entering 177 + # its own meta.adapter). The excluded-guard is a no-op in the raw 178 + # use since tombstones only appear after filterIncludes runs. 179 + collectPathsInner = 180 + { aspect, recurse, ... }: 181 + { 182 + paths = 183 + (lib.optional (!(aspect.meta.excluded or false)) (aspectPath aspect)) 184 + ++ lib.concatMap (i: (recurse i).paths or [ ]) (aspect.includes or [ ]); 185 + }; 186 + 187 + # Terminal adapter that walks via filterIncludes and collects the 188 + # aspectPath of every non-tombstone aspect. Result shape: 189 + # { paths = [ [providerSeg..., name], ... ]; }. Depth-first, not deduped. 190 + collectPaths = filterIncludes collectPathsInner; 191 + 192 + # meta.adapter that keeps the first candidate structurally present 193 + # in the parent subtree and tombstones the rest via excludeAspect. 194 + # 195 + # meta.adapter = oneOfAspects [ <agenix-rekey> <sops-nix> ]; 196 + # 197 + # No-op when no candidates are present. Presence is determined 198 + # from the raw tree (bypassing filterIncludes) so we don't re-enter 199 + # our own meta.adapter. 200 + oneOfAspects = 201 + candidates: inherited: 202 + args@{ class, aspect-chain, ... }: 203 + let 204 + # filterIncludes rebinds args.aspect to each child but keeps 205 + # aspect-chain, whose tail is still the parent that owns us. 206 + parent = lib.last aspect-chain; 207 + subtree = den.lib.aspects.resolve.withAdapter collectPathsInner class parent; 208 + present-keys = toPathSet (subtree.paths or [ ]); 209 + keyOf = c: pathKey (aspectPath c); 210 + present = builtins.filter (c: present-keys ? ${keyOf c}) candidates; 211 + losers = if present == [ ] then [ ] else builtins.tail present; 212 + in 213 + (lib.foldl' (inner: loser: excludeAspect loser inner) inherited losers) args; 214 + 161 215 # Traces aspect.name as nested lists per includes. Composed with filterIncludes 162 216 # so tombstones and substitutions are visible. 163 217 # ··· 178 232 { 179 233 inherit 180 234 aspectPath 235 + collectPaths 181 236 default 182 237 excludeAspect 183 238 filter ··· 186 241 mapAspect 187 242 mapIncludes 188 243 module 244 + oneOfAspects 245 + pathKey 189 246 substituteAspect 247 + toPathSet 190 248 tombstone 191 249 trace 192 250 ;
+2
nix/lib/aspects/default.nix
··· 7 7 rawTypes = import ./types.nix { inherit den lib; }; 8 8 adapters = import ./adapters.nix { inherit den lib; }; 9 9 resolve = import ./resolve.nix { inherit den lib; }; 10 + hasAspect = import ./has-aspect.nix { inherit den lib; }; 10 11 11 12 defaultFunctor = (den.lib.parametric { }).__functor; 12 13 typesConf = { inherit defaultFunctor; }; ··· 14 15 in 15 16 { 16 17 inherit types adapters resolve; 18 + inherit (hasAspect) hasAspectIn collectPathSet mkEntityHasAspect; 17 19 mkAspectsType = cnf': lib.mapAttrs (_: v: v (typesConf // cnf')) rawTypes; 18 20 }
+65
nix/lib/aspects/has-aspect.nix
··· 1 + # Query whether an aspect is structurally present in a resolved tree. 2 + # Entity-facing wiring lives in modules/context/has-aspect.nix. 3 + { lib, den, ... }: 4 + let 5 + inherit (den.lib.aspects) adapters resolve; 6 + inherit (adapters) pathKey toPathSet; 7 + 8 + # Validate a ref has both `name` and `meta` (aspectPath requires 9 + # both) and return its slash-joined path key. 10 + refKey = 11 + ref: 12 + if (ref ? name) && (ref ? meta) then 13 + pathKey (adapters.aspectPath ref) 14 + else 15 + throw "hasAspect: ref must have both `name` and `meta` (got ${builtins.typeOf ref})."; 16 + 17 + # Run collectPaths under `class` on `tree`, returned as an 18 + # attrset-as-set keyed by slash-joined path. 19 + collectPathSet = 20 + { tree, class }: toPathSet ((resolve.withAdapter adapters.collectPaths class tree).paths or [ ]); 21 + 22 + hasAspectIn = 23 + { 24 + tree, 25 + class, 26 + ref, 27 + }: 28 + (collectPathSet { inherit tree class; }) ? ${refKey ref}; 29 + 30 + # Build the functor+attrs value attached to entities as `.hasAspect`. 31 + # Per-class path sets are thunk-cached inside `setFor` so repeated 32 + # calls share one traversal per class. 33 + mkEntityHasAspect = 34 + { 35 + tree, 36 + primaryClass, 37 + classes, 38 + }: 39 + let 40 + setFor = builtins.listToAttrs ( 41 + map (c: { 42 + name = c; 43 + value = collectPathSet { 44 + inherit tree; 45 + class = c; 46 + }; 47 + }) (lib.unique ([ primaryClass ] ++ classes)) 48 + ); 49 + check = class: ref: (setFor.${class} or { }) ? ${refKey ref}; 50 + bareFn = check primaryClass; 51 + in 52 + { 53 + __functor = _: bareFn; 54 + forClass = check; 55 + forAnyClass = ref: lib.any (c: check c ref) classes; 56 + }; 57 + 58 + in 59 + { 60 + inherit 61 + hasAspectIn 62 + collectPathSet 63 + mkEntityHasAspect 64 + ; 65 + }
+161
templates/ci/modules/features/collect-paths.nix
··· 1 + # Tests for den.lib.aspects.adapters.collectPaths — the path-collecting 2 + # terminal adapter used by hasAspect and other structural-query tooling. 3 + { denTest, lib, ... }: 4 + { 5 + flake.tests.collect-paths = { 6 + 7 + test-basic-static-tree = denTest ( 8 + { den, ... }: 9 + let 10 + inherit (den.lib.aspects) resolve adapters; 11 + paths = (resolve.withAdapter adapters.collectPaths "nixos" den.aspects.foo).paths or [ ]; 12 + toKey = p: lib.concatStringsSep "/" p; 13 + keys = map toKey paths; 14 + in 15 + { 16 + den.aspects.foo.includes = [ 17 + den.aspects.bar 18 + den.aspects.baz 19 + ]; 20 + den.aspects.bar.nixos = { }; 21 + den.aspects.baz.nixos = { }; 22 + 23 + expr = { 24 + hasFoo = lib.elem "foo" keys; 25 + hasBar = lib.elem "bar" keys; 26 + hasBaz = lib.elem "baz" keys; 27 + # Depth-first: foo visited first (it's the root of the walk). 28 + firstIsFoo = builtins.head keys == "foo"; 29 + }; 30 + expected = { 31 + hasFoo = true; 32 + hasBar = true; 33 + hasBaz = true; 34 + firstIsFoo = true; 35 + }; 36 + } 37 + ); 38 + 39 + test-empty-tree = denTest ( 40 + { den, ... }: 41 + let 42 + inherit (den.lib.aspects) resolve adapters; 43 + result = resolve.withAdapter adapters.collectPaths "nixos" den.aspects.alone; 44 + in 45 + { 46 + den.aspects.alone = { }; 47 + 48 + expr = { 49 + pathCount = builtins.length (result.paths or [ ]); 50 + hasSelf = builtins.elem [ "alone" ] (result.paths or [ ]); 51 + }; 52 + expected = { 53 + pathCount = 1; 54 + hasSelf = true; 55 + }; 56 + } 57 + ); 58 + 59 + test-forces-parametric-functors = denTest ( 60 + { den, ... }: 61 + let 62 + inherit (den.lib.aspects) resolve adapters; 63 + paths = (resolve.withAdapter adapters.collectPaths "nixos" den.aspects.role).paths or [ ]; 64 + keys = map (lib.concatStringsSep "/") paths; 65 + in 66 + { 67 + # role (static) includes a perHost parametric aspect; collectPaths 68 + # should force the functor and include its entry in the path list. 69 + den.aspects.role.includes = [ 70 + den.aspects.leaf 71 + den.aspects.param 72 + ]; 73 + den.aspects.leaf.nixos = { }; 74 + den.aspects.param = den.lib.perHost ( 75 + { host }: 76 + { 77 + nixos = { }; 78 + } 79 + ); 80 + 81 + expr = { 82 + hasRole = lib.elem "role" keys; 83 + hasLeaf = lib.elem "leaf" keys; 84 + hasParam = lib.elem "param" keys; 85 + }; 86 + expected = { 87 + hasRole = true; 88 + hasLeaf = true; 89 + hasParam = true; 90 + }; 91 + } 92 + ); 93 + 94 + test-skips-tombstones = denTest ( 95 + { den, ... }: 96 + let 97 + inherit (den.lib.aspects) resolve adapters; 98 + paths = (resolve.withAdapter adapters.collectPaths "nixos" den.aspects.root).paths or [ ]; 99 + keys = map (lib.concatStringsSep "/") paths; 100 + in 101 + { 102 + den.aspects.root.includes = [ 103 + den.aspects.keep 104 + den.aspects.dropme 105 + ]; 106 + den.aspects.root.meta.adapter = inherited: adapters.excludeAspect den.aspects.dropme inherited; 107 + den.aspects.keep.nixos = { }; 108 + den.aspects.dropme.nixos = { }; 109 + 110 + expr = { 111 + hasKeep = lib.elem "keep" keys; 112 + hasDropme = lib.elem "dropme" keys; 113 + }; 114 + expected = { 115 + hasKeep = true; 116 + hasDropme = false; 117 + }; 118 + } 119 + ); 120 + 121 + test-shared-subtree-not-deduped = denTest ( 122 + { den, ... }: 123 + let 124 + inherit (den.lib.aspects) resolve adapters; 125 + paths = (resolve.withAdapter adapters.collectPaths "nixos" den.aspects.root).paths or [ ]; 126 + keys = map (lib.concatStringsSep "/") paths; 127 + sharedCount = builtins.length (builtins.filter (k: k == "shared") keys); 128 + in 129 + { 130 + # `shared` reached via both `a` and `b`. 131 + den.aspects.root.includes = [ 132 + den.aspects.a 133 + den.aspects.b 134 + ]; 135 + den.aspects.a.includes = [ den.aspects.shared ]; 136 + den.aspects.b.includes = [ den.aspects.shared ]; 137 + den.aspects.shared.nixos = { }; 138 + 139 + # collectPaths does NOT dedupe — each visit produces a path. 140 + expr = sharedCount; 141 + expected = 2; 142 + } 143 + ); 144 + 145 + test-provider-path-included = denTest ( 146 + { den, ... }: 147 + let 148 + inherit (den.lib.aspects) resolve adapters; 149 + paths = (resolve.withAdapter adapters.collectPaths "nixos" den.aspects.root).paths or [ ]; 150 + in 151 + { 152 + den.aspects.root.includes = [ den.aspects.foo._.sub ]; 153 + den.aspects.foo._.sub.nixos = { }; 154 + 155 + expr = lib.elem [ "foo" "sub" ] paths; 156 + expected = true; 157 + } 158 + ); 159 + 160 + }; 161 + }
+245
templates/ci/modules/features/has-aspect-lib.nix
··· 1 + # Tests for the lib primitives: hasAspectIn, collectPathSet, 2 + # mkEntityHasAspect. These exercise the query mechanics without any 3 + # entity wiring — see has-aspect.nix for the entity-method tests. 4 + { denTest, lib, ... }: 5 + { 6 + flake.tests.has-aspect-lib = { 7 + 8 + test-hasAspectIn-positive = denTest ( 9 + { den, ... }: 10 + let 11 + inherit (den.lib.aspects) hasAspectIn; 12 + in 13 + { 14 + den.aspects.root.includes = [ den.aspects.child ]; 15 + den.aspects.child.nixos = { }; 16 + 17 + expr = hasAspectIn { 18 + tree = den.aspects.root; 19 + class = "nixos"; 20 + ref = den.aspects.child; 21 + }; 22 + expected = true; 23 + } 24 + ); 25 + 26 + test-hasAspectIn-negative = denTest ( 27 + { den, ... }: 28 + let 29 + inherit (den.lib.aspects) hasAspectIn; 30 + in 31 + { 32 + den.aspects.root.includes = [ den.aspects.child ]; 33 + den.aspects.child.nixos = { }; 34 + den.aspects.other.nixos = { }; 35 + 36 + expr = hasAspectIn { 37 + tree = den.aspects.root; 38 + class = "nixos"; 39 + ref = den.aspects.other; 40 + }; 41 + expected = false; 42 + } 43 + ); 44 + 45 + test-hasAspectIn-respects-tombstones = denTest ( 46 + { den, ... }: 47 + let 48 + inherit (den.lib.aspects) hasAspectIn adapters; 49 + in 50 + { 51 + den.aspects.root.includes = [ 52 + den.aspects.keep 53 + den.aspects.drop 54 + ]; 55 + den.aspects.root.meta.adapter = inherited: adapters.excludeAspect den.aspects.drop inherited; 56 + den.aspects.keep.nixos = { }; 57 + den.aspects.drop.nixos = { }; 58 + 59 + expr = { 60 + keep = hasAspectIn { 61 + tree = den.aspects.root; 62 + class = "nixos"; 63 + ref = den.aspects.keep; 64 + }; 65 + drop = hasAspectIn { 66 + tree = den.aspects.root; 67 + class = "nixos"; 68 + ref = den.aspects.drop; 69 + }; 70 + }; 71 + expected = { 72 + keep = true; 73 + drop = false; 74 + }; 75 + } 76 + ); 77 + 78 + test-collectPathSet-returns-keys = denTest ( 79 + { den, ... }: 80 + let 81 + inherit (den.lib.aspects) collectPathSet; 82 + s = collectPathSet { 83 + tree = den.aspects.root; 84 + class = "nixos"; 85 + }; 86 + in 87 + { 88 + den.aspects.root.includes = [ den.aspects.foo._.bar ]; 89 + den.aspects.foo._.bar.nixos = { }; 90 + 91 + expr = { 92 + hasRoot = s ? "root"; 93 + hasSub = s ? "foo/bar"; 94 + hasNothing = s ? "nonexistent"; 95 + }; 96 + expected = { 97 + hasRoot = true; 98 + hasSub = true; 99 + hasNothing = false; 100 + }; 101 + } 102 + ); 103 + 104 + test-mkEntityHasAspect-shape = denTest ( 105 + { den, ... }: 106 + let 107 + inherit (den.lib.aspects) mkEntityHasAspect; 108 + has = mkEntityHasAspect { 109 + tree = den.aspects.root; 110 + primaryClass = "nixos"; 111 + classes = [ "nixos" ]; 112 + }; 113 + in 114 + { 115 + den.aspects.root.includes = [ den.aspects.child ]; 116 + den.aspects.child.nixos = { }; 117 + 118 + expr = { 119 + isAttrs = builtins.isAttrs has; 120 + hasForClass = has ? forClass; 121 + hasForAnyClass = has ? forAnyClass; 122 + callableBare = has den.aspects.child; 123 + callableForClass = has.forClass "nixos" den.aspects.child; 124 + callableForAnyClass = has.forAnyClass den.aspects.child; 125 + }; 126 + expected = { 127 + isAttrs = true; 128 + hasForClass = true; 129 + hasForAnyClass = true; 130 + callableBare = true; 131 + callableForClass = true; 132 + callableForAnyClass = true; 133 + }; 134 + } 135 + ); 136 + 137 + test-mkEntityHasAspect-absent = denTest ( 138 + { den, ... }: 139 + let 140 + inherit (den.lib.aspects) mkEntityHasAspect; 141 + has = mkEntityHasAspect { 142 + tree = den.aspects.root; 143 + primaryClass = "nixos"; 144 + classes = [ "nixos" ]; 145 + }; 146 + in 147 + { 148 + den.aspects.root.nixos = { }; 149 + den.aspects.unrelated.nixos = { }; 150 + 151 + expr = has den.aspects.unrelated; 152 + expected = false; 153 + } 154 + ); 155 + 156 + test-mkEntityHasAspect-forClass-unknown-class = denTest ( 157 + { den, ... }: 158 + let 159 + inherit (den.lib.aspects) mkEntityHasAspect; 160 + has = mkEntityHasAspect { 161 + tree = den.aspects.root; 162 + primaryClass = "nixos"; 163 + classes = [ "nixos" ]; 164 + }; 165 + in 166 + { 167 + den.aspects.root.includes = [ den.aspects.child ]; 168 + den.aspects.child.nixos = { }; 169 + 170 + # Unknown class returns false silently, not an error. 171 + expr = has.forClass "bogus-class" den.aspects.child; 172 + expected = false; 173 + } 174 + ); 175 + 176 + test-refKey-validator-throws-on-string = denTest ( 177 + { den, ... }: 178 + let 179 + inherit (den.lib.aspects) hasAspectIn; 180 + result = builtins.tryEval (hasAspectIn { 181 + tree = den.aspects.root; 182 + class = "nixos"; 183 + ref = "not-an-aspect"; 184 + }); 185 + in 186 + { 187 + den.aspects.root.nixos = { }; 188 + 189 + # tryEval returns { success = false; value = false; } on throw 190 + expr = result.success; 191 + expected = false; 192 + } 193 + ); 194 + 195 + test-refKey-validator-throws-on-bare-meta = denTest ( 196 + { den, ... }: 197 + let 198 + inherit (den.lib.aspects) hasAspectIn; 199 + result = builtins.tryEval (hasAspectIn { 200 + tree = den.aspects.root; 201 + class = "nixos"; 202 + # missing `name` — must throw 203 + ref = { 204 + meta.provider = [ "x" ]; 205 + }; 206 + }); 207 + in 208 + { 209 + den.aspects.root.nixos = { }; 210 + 211 + expr = result.success; 212 + expected = false; 213 + } 214 + ); 215 + 216 + # Factory-function aspects (`den.aspects.myFactory = arg: {...}`) 217 + # are merged through providerFnType and aspectSubmodule into an 218 + # attrset-with-__functor. The factory itself has a stable 219 + # aspectPath derived from its declaration name, so querying 220 + # `host.hasAspect den.aspects.myFactory` is well-defined. 221 + test-factory-fn-aspect-identity = denTest ( 222 + { den, lib, ... }: 223 + let 224 + inherit (den.lib.aspects) adapters; 225 + in 226 + { 227 + den.aspects.myFactory = arg: { 228 + nixos.environment.variables.FACTORY_ARG = arg; 229 + }; 230 + # Reference the factory so submodule merging runs. 231 + den.aspects.consumer.includes = [ (den.aspects.myFactory "/x") ]; 232 + 233 + expr = { 234 + factoryPath = adapters.aspectPath den.aspects.myFactory; 235 + factoryIsFunction = builtins.isFunction den.aspects.myFactory; 236 + }; 237 + expected = { 238 + factoryPath = [ "myFactory" ]; 239 + factoryIsFunction = false; 240 + }; 241 + } 242 + ); 243 + 244 + }; 245 + }
+669
templates/ci/modules/features/has-aspect.nix
··· 1 + # End-to-end tests for entity.hasAspect organized by aspect shape. 2 + # Every aspect-construction shape that has produced a regression 3 + # (#408, #413, #423, #429) has a lock-in test here, so a future 4 + # regression in parametric.nix or aspects/types.nix that affects any 5 + # of those shapes fails a hasAspect test before it reaches user code. 6 + # 7 + # Note: `igloo` from denTest specialArgs is the resolved NixOS config 8 + # (config.flake.nixosConfigurations.igloo.config), not the den host 9 + # entity. The host entity — which is where `hasAspect` lives — is 10 + # reached via `den.hosts.x86_64-linux.igloo`. Same for users: 11 + # `den.hosts.x86_64-linux.igloo.users.tux`. 12 + { denTest, lib, ... }: 13 + { 14 + flake.tests.has-aspect = { 15 + 16 + test-host-hasAspect-present-static = denTest ( 17 + { den, ... }: 18 + { 19 + den.hosts.x86_64-linux.igloo.users.tux = { }; 20 + 21 + den.aspects.igloo.includes = [ den.aspects.feature ]; 22 + den.aspects.feature.nixos = { }; 23 + 24 + # host.hasAspect is available because host imports 25 + # den.schema.conf which imports modules/context/has-aspect.nix. 26 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.feature; 27 + expected = true; 28 + } 29 + ); 30 + 31 + test-host-hasAspect-absent = denTest ( 32 + { den, ... }: 33 + { 34 + den.hosts.x86_64-linux.igloo.users.tux = { }; 35 + 36 + den.aspects.igloo.nixos = { }; 37 + den.aspects.unrelated.nixos = { }; 38 + 39 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.unrelated; 40 + expected = false; 41 + } 42 + ); 43 + 44 + test-user-hasAspect-present = denTest ( 45 + { den, ... }: 46 + { 47 + den.hosts.x86_64-linux.igloo.users.tux = { }; 48 + 49 + # denTest's default is classes = ["homeManager"] for users. 50 + den.aspects.tux.includes = [ den.aspects.user-feature ]; 51 + den.aspects.user-feature.homeManager = { }; 52 + 53 + expr = den.hosts.x86_64-linux.igloo.users.tux.hasAspect den.aspects.user-feature; 54 + expected = true; 55 + } 56 + ); 57 + 58 + test-hasAspect-forClass-explicit = denTest ( 59 + { den, ... }: 60 + { 61 + den.hosts.x86_64-linux.igloo.users.tux = { }; 62 + 63 + den.aspects.igloo.includes = [ den.aspects.feature ]; 64 + den.aspects.feature.nixos = { }; 65 + 66 + expr = den.hosts.x86_64-linux.igloo.hasAspect.forClass "nixos" den.aspects.feature; 67 + expected = true; 68 + } 69 + ); 70 + 71 + test-hasAspect-forAnyClass = denTest ( 72 + { den, ... }: 73 + { 74 + den.hosts.x86_64-linux.igloo.users.tux = { }; 75 + 76 + den.aspects.igloo.includes = [ den.aspects.feature ]; 77 + den.aspects.feature.nixos = { }; 78 + 79 + expr = den.hosts.x86_64-linux.igloo.hasAspect.forAnyClass den.aspects.feature; 80 + expected = true; 81 + } 82 + ); 83 + 84 + test-hasAspect-respects-tombstone = denTest ( 85 + { den, ... }: 86 + { 87 + den.hosts.x86_64-linux.igloo.users.tux = { }; 88 + 89 + den.aspects.igloo.includes = [ 90 + den.aspects.keep 91 + den.aspects.drop 92 + ]; 93 + den.aspects.igloo.meta.adapter = 94 + inherited: den.lib.aspects.adapters.excludeAspect den.aspects.drop inherited; 95 + den.aspects.keep.nixos = { }; 96 + den.aspects.drop.nixos = { }; 97 + 98 + expr = { 99 + keep = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.keep; 100 + drop = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.drop; 101 + }; 102 + expected = { 103 + keep = true; 104 + drop = false; 105 + }; 106 + } 107 + ); 108 + 109 + test-hasAspect-angle-bracket-equivalent = denTest ( 110 + { den, __findFile, ... }: 111 + { 112 + _module.args.__findFile = den.lib.__findFile; 113 + 114 + den.hosts.x86_64-linux.igloo.users.tux = { }; 115 + 116 + den.aspects.feature.nixos = { }; 117 + den.aspects.igloo.includes = [ den.aspects.feature ]; 118 + 119 + # <feature> sugar resolves to den.aspects.feature via __findFile. 120 + expr = { 121 + viaAttr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.feature; 122 + viaAngle = den.hosts.x86_64-linux.igloo.hasAspect <feature>; 123 + }; 124 + expected = { 125 + viaAttr = true; 126 + viaAngle = true; 127 + }; 128 + } 129 + ); 130 + 131 + # ─── Group A: sanity completions ────────────────────────────────── 132 + 133 + test-A-hosts-hasAspect-self = denTest ( 134 + { den, ... }: 135 + { 136 + den.hosts.x86_64-linux.igloo.users.tux = { }; 137 + den.aspects.igloo.nixos = { }; 138 + 139 + # Host reports its own root aspect as present. 140 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.igloo; 141 + expected = true; 142 + } 143 + ); 144 + 145 + test-A-hosts-hasAspect-chained-transitively = denTest ( 146 + { den, ... }: 147 + { 148 + den.hosts.x86_64-linux.igloo.users.tux = { }; 149 + 150 + den.aspects.igloo.includes = [ den.aspects.level1 ]; 151 + den.aspects.level1.includes = [ den.aspects.level2 ]; 152 + den.aspects.level2.includes = [ den.aspects.level3 ]; 153 + den.aspects.level3.nixos = { }; 154 + 155 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.level3; 156 + expected = true; 157 + } 158 + ); 159 + 160 + # ─── Group B: parametric contexts (regression locks) ────────────── 161 + 162 + # Baseline parametric parent with static child. 163 + test-B-present-via-parametric-parent = denTest ( 164 + { den, ... }: 165 + { 166 + den.hosts.x86_64-linux.igloo.users.tux = { }; 167 + 168 + den.aspects.igloo.includes = [ den.aspects.parent ]; 169 + den.aspects.parent = 170 + { host, ... }: 171 + { 172 + includes = [ den.aspects.child ]; 173 + }; 174 + den.aspects.child.nixos = { }; 175 + 176 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.child; 177 + expected = true; 178 + } 179 + ); 180 + 181 + # #423 regression shape — parametric parent with static sub-aspect. 182 + # If applyDeep regresses, role._.sub vanishes from the tree. 183 + test-B-present-via-static-sub-aspect-in-parametric-parent = denTest ( 184 + { den, ... }: 185 + { 186 + den.hosts.x86_64-linux.igloo.users.tux = { }; 187 + 188 + imports = [ 189 + { 190 + den.aspects.role = 191 + { host, ... }: 192 + { 193 + includes = [ den.aspects.role._.sub ]; 194 + }; 195 + } 196 + { 197 + den.aspects.role._.sub.nixos.networking.networkmanager.enable = true; 198 + } 199 + { 200 + den.aspects.igloo.includes = [ den.aspects.role ]; 201 + } 202 + ]; 203 + 204 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.role._.sub; 205 + expected = true; 206 + } 207 + ); 208 + 209 + # #413 shape — parametric parent unconditionally includes a 210 + # bare-function provider sub-aspect. Exercises the applyDeep 211 + # inner-recursion + meta-carryover path. 212 + test-B-present-via-bare-function-sub-aspect = denTest ( 213 + { den, ... }: 214 + { 215 + den.hosts.x86_64-linux.igloo.users.tux = { }; 216 + 217 + imports = [ 218 + { 219 + den.aspects.foo = 220 + { host, ... }: 221 + { 222 + includes = [ den.aspects.foo._.sub ]; 223 + }; 224 + } 225 + { 226 + den.aspects.foo._.sub = 227 + { host, ... }: 228 + { 229 + nixos.networking.networkmanager.enable = true; 230 + }; 231 + } 232 + { 233 + den.aspects.igloo.includes = [ den.aspects.foo ]; 234 + } 235 + ]; 236 + 237 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.foo._.sub; 238 + expected = true; 239 + } 240 + ); 241 + 242 + test-B-absent-when-parametric-parent-omits = denTest ( 243 + { den, lib, ... }: 244 + { 245 + den.hosts.x86_64-linux.igloo.users.tux = { }; 246 + 247 + den.aspects.igloo.includes = [ den.aspects.gated ]; 248 + den.aspects.gated = 249 + { host, ... }: 250 + { 251 + includes = lib.optional (host.name == "other-host") den.aspects.conditional; 252 + }; 253 + den.aspects.conditional.nixos = { }; 254 + 255 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.conditional; 256 + expected = false; 257 + } 258 + ); 259 + 260 + # ─── Group C: factory functions ─────────────────────────────────── 261 + 262 + # Factory-fn aspects have a stable aspectPath derived from their 263 + # declaration name and can be queried directly when referenced in 264 + # includes. Note: invoking the factory inline (`facter arg`) 265 + # produces an anonymous sibling aspect with a loc-derived name — 266 + # factory identity is NOT inherited by instances. 267 + test-C-factory-fn-aspect-present = denTest ( 268 + { den, ... }: 269 + { 270 + den.hosts.x86_64-linux.igloo.users.tux = { }; 271 + 272 + den.aspects.facter = reportPath: { 273 + nixos.environment.variables.FACTER_REPORT = reportPath; 274 + }; 275 + den.aspects.igloo.includes = [ den.aspects.facter ]; 276 + 277 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.facter; 278 + expected = true; 279 + } 280 + ); 281 + 282 + # #408 / #429 shape — parametric function + static sibling merging. 283 + test-C-factory-fn-merged-with-static-sibling = denTest ( 284 + { den, ... }: 285 + { 286 + den.hosts.x86_64-linux.igloo.users.tux = { }; 287 + 288 + imports = [ 289 + { 290 + den.aspects.mixed = 291 + { host, ... }: 292 + { 293 + nixos.environment.variables.MIXED_HOST = host.name; 294 + }; 295 + } 296 + { 297 + den.aspects.mixed.nixos.environment.variables.MIXED_STATIC = "yes"; 298 + } 299 + { 300 + den.aspects.igloo.includes = [ den.aspects.mixed ]; 301 + } 302 + ]; 303 + 304 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.mixed; 305 + expected = true; 306 + } 307 + ); 308 + 309 + # ─── Group D: provider sub-aspects ──────────────────────────────── 310 + 311 + test-D-static-provider-sub-present = denTest ( 312 + { den, ... }: 313 + { 314 + den.hosts.x86_64-linux.igloo.users.tux = { }; 315 + 316 + den.aspects.igloo.includes = [ den.aspects.foo._.sub ]; 317 + den.aspects.foo._.sub.nixos = { }; 318 + 319 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.foo._.sub; 320 + expected = true; 321 + } 322 + ); 323 + 324 + # Function-bodied provider sub-aspect reached via direct inclusion 325 + # — exercises the applyDeep outer-result meta-carryover path. 326 + test-D-parametric-provider-sub-present = denTest ( 327 + { den, ... }: 328 + { 329 + den.hosts.x86_64-linux.igloo.users.tux = { }; 330 + 331 + den.aspects.igloo.includes = [ den.aspects.foo._.sub ]; 332 + den.aspects.foo._.sub = 333 + { host, ... }: 334 + { 335 + nixos.environment.variables.FOO_SUB = host.name; 336 + }; 337 + 338 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.foo._.sub; 339 + expected = true; 340 + } 341 + ); 342 + 343 + test-D-provider-sub-identity-distinct-from-homonym = denTest ( 344 + { den, ... }: 345 + { 346 + den.hosts.x86_64-linux.igloo.users.tux = { }; 347 + 348 + den.aspects.igloo.includes = [ den.aspects.bar._.foo ]; 349 + den.aspects.bar._.foo.nixos = { }; 350 + # `foo` also exists at top level — different aspectPath. 351 + den.aspects.foo.nixos = { }; 352 + 353 + expr = { 354 + sub = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.bar._.foo; 355 + top = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.foo; 356 + }; 357 + expected = { 358 + sub = true; 359 + top = false; 360 + }; 361 + } 362 + ); 363 + 364 + # ─── Group E: mutual-provider / provides chains ─────────────────── 365 + 366 + test-E-present-via-provides-to-users = denTest ( 367 + { den, ... }: 368 + { 369 + den.ctx.user.includes = [ den._.mutual-provider ]; 370 + den.hosts.x86_64-linux.igloo.users.tux = { }; 371 + 372 + den.aspects.igloo.provides.to-users = { 373 + includes = [ den.aspects.user-target ]; 374 + }; 375 + den.aspects.user-target.homeManager = { }; 376 + 377 + expr = den.hosts.x86_64-linux.igloo.users.tux.hasAspect den.aspects.user-target; 378 + expected = true; 379 + } 380 + ); 381 + 382 + test-E-present-via-provides-specific-user = denTest ( 383 + { den, ... }: 384 + { 385 + den.ctx.user.includes = [ den._.mutual-provider ]; 386 + den.hosts.x86_64-linux.igloo.users.tux = { }; 387 + den.hosts.x86_64-linux.igloo.users.alice = { }; 388 + 389 + den.aspects.igloo.provides.alice = { 390 + includes = [ den.aspects.alice-only ]; 391 + }; 392 + den.aspects.alice-only.homeManager = { }; 393 + 394 + expr = { 395 + alice = den.hosts.x86_64-linux.igloo.users.alice.hasAspect den.aspects.alice-only; 396 + tux = den.hosts.x86_64-linux.igloo.users.tux.hasAspect den.aspects.alice-only; 397 + }; 398 + expected = { 399 + alice = true; 400 + tux = false; 401 + }; 402 + } 403 + ); 404 + 405 + test-E-present-via-user-to-hosts = denTest ( 406 + { den, ... }: 407 + { 408 + den.ctx.user.includes = [ den._.mutual-provider ]; 409 + den.hosts.x86_64-linux.igloo.users.tux = { }; 410 + 411 + den.aspects.tux.provides.to-hosts = { 412 + includes = [ den.aspects.host-target ]; 413 + }; 414 + den.aspects.host-target.nixos = { }; 415 + 416 + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.host-target; 417 + expected = true; 418 + } 419 + ); 420 + 421 + # ─── Group F: meta.adapter interactions ─────────────────────────── 422 + 423 + test-F-respects-substituteAspect = denTest ( 424 + { den, ... }: 425 + { 426 + den.hosts.x86_64-linux.igloo.users.tux = { }; 427 + 428 + den.aspects.igloo.includes = [ den.aspects.original ]; 429 + den.aspects.igloo.meta.adapter = 430 + inherited: 431 + den.lib.aspects.adapters.substituteAspect den.aspects.original den.aspects.replacement inherited; 432 + den.aspects.original.nixos = { }; 433 + den.aspects.replacement.nixos = { }; 434 + 435 + expr = { 436 + original = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.original; 437 + replacement = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.replacement; 438 + }; 439 + expected = { 440 + original = false; 441 + replacement = true; 442 + }; 443 + } 444 + ); 445 + 446 + # Two adapters at DIFFERENT levels, each tombstoning a direct child 447 + # of its own aspect. Nested-reaching does NOT work because 448 + # filterIncludes.tag only stamps a parent's adapter onto children 449 + # without their own adapter — children with adapters keep theirs. 450 + # This test exercises composition that works: each adapter affects 451 + # its own subtree. 452 + test-F-composes-at-different-levels = denTest ( 453 + { den, ... }: 454 + { 455 + den.hosts.x86_64-linux.igloo.users.tux = { }; 456 + 457 + # igloo's adapter tombstones root-sibling at its own level. 458 + den.aspects.igloo.includes = [ 459 + den.aspects.parent 460 + den.aspects.root-sibling 461 + ]; 462 + den.aspects.igloo.meta.adapter = 463 + inherited: den.lib.aspects.adapters.excludeAspect den.aspects.root-sibling inherited; 464 + 465 + # parent's adapter tombstones parent-sibling at its own level. 466 + den.aspects.parent.includes = [ 467 + den.aspects.child-a 468 + den.aspects.parent-sibling 469 + ]; 470 + den.aspects.parent.meta.adapter = 471 + inherited: den.lib.aspects.adapters.excludeAspect den.aspects.parent-sibling inherited; 472 + 473 + den.aspects.root-sibling.nixos = { }; 474 + den.aspects.parent-sibling.nixos = { }; 475 + den.aspects.child-a.nixos = { }; 476 + 477 + # Each adapter tombstones its own direct-child target; 478 + # child-a survives under parent. 479 + expr = { 480 + rootSibling = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.root-sibling; 481 + parentSibling = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.parent-sibling; 482 + childA = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.child-a; 483 + }; 484 + expected = { 485 + rootSibling = false; 486 + parentSibling = false; 487 + childA = true; 488 + }; 489 + } 490 + ); 491 + 492 + test-F-respects-oneOfAspects = denTest ( 493 + { den, ... }: 494 + { 495 + den.hosts.x86_64-linux.igloo.users.tux = { }; 496 + 497 + den.aspects.igloo.includes = [ den.aspects.bundle ]; 498 + den.aspects.bundle.includes = [ 499 + den.aspects.primary 500 + den.aspects.fallback 501 + ]; 502 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 503 + den.aspects.primary 504 + den.aspects.fallback 505 + ]; 506 + den.aspects.primary.nixos = { }; 507 + den.aspects.fallback.nixos = { }; 508 + 509 + expr = { 510 + primary = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.primary; 511 + fallback = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.fallback; 512 + }; 513 + expected = { 514 + primary = true; 515 + fallback = false; 516 + }; 517 + } 518 + ); 519 + 520 + # ─── Group G: multi-class users ─────────────────────────────────── 521 + 522 + test-G-user-hasAspect-primary-class = denTest ( 523 + { den, lib, ... }: 524 + { 525 + den.schema.user.classes = lib.mkForce [ 526 + "user" 527 + "homeManager" 528 + ]; 529 + den.hosts.x86_64-linux.igloo.users.tux = { }; 530 + 531 + den.aspects.tux.includes = [ den.aspects.target ]; 532 + den.aspects.target.user = { }; 533 + den.aspects.target.homeManager = { }; 534 + 535 + # Primary class = lib.head classes = "user" 536 + expr = den.hosts.x86_64-linux.igloo.users.tux.hasAspect den.aspects.target; 537 + expected = true; 538 + } 539 + ); 540 + 541 + test-G-user-hasAspect-forClass-explicit = denTest ( 542 + { den, lib, ... }: 543 + { 544 + den.schema.user.classes = lib.mkForce [ 545 + "user" 546 + "homeManager" 547 + ]; 548 + den.hosts.x86_64-linux.igloo.users.tux = { }; 549 + 550 + den.aspects.tux.includes = [ den.aspects.target ]; 551 + den.aspects.target.homeManager = { }; 552 + 553 + expr = { 554 + user = den.hosts.x86_64-linux.igloo.users.tux.hasAspect.forClass "user" den.aspects.target; 555 + hm = den.hosts.x86_64-linux.igloo.users.tux.hasAspect.forClass "homeManager" den.aspects.target; 556 + }; 557 + # Structural tree is class-invariant — both return true. 558 + expected = { 559 + user = true; 560 + hm = true; 561 + }; 562 + } 563 + ); 564 + 565 + test-G-user-hasAspect-forAnyClass-matches-any = denTest ( 566 + { den, lib, ... }: 567 + { 568 + den.schema.user.classes = lib.mkForce [ 569 + "user" 570 + "homeManager" 571 + ]; 572 + den.hosts.x86_64-linux.igloo.users.tux = { }; 573 + 574 + den.aspects.tux.includes = [ den.aspects.target ]; 575 + den.aspects.target.homeManager = { }; 576 + 577 + expr = den.hosts.x86_64-linux.igloo.users.tux.hasAspect.forAnyClass den.aspects.target; 578 + expected = true; 579 + } 580 + ); 581 + 582 + test-G-user-hasAspect-forClass-unknown-class = denTest ( 583 + { den, ... }: 584 + { 585 + den.hosts.x86_64-linux.igloo.users.tux = { }; 586 + 587 + den.aspects.tux.includes = [ den.aspects.target ]; 588 + den.aspects.target.homeManager = { }; 589 + 590 + # Unknown class: returns false silently, no error. 591 + expr = den.hosts.x86_64-linux.igloo.users.tux.hasAspect.forClass "bogus" den.aspects.target; 592 + expected = false; 593 + } 594 + ); 595 + 596 + # ─── Group H: extensibility ─────────────────────────────────────── 597 + 598 + # Verify den.schema.conf owns the hasAspect option — not 599 + # host/user/home individually. Any entity kind importing conf 600 + # inherits hasAspect. 601 + test-H-conf-option-exists = denTest ( 602 + { den, ... }: 603 + { 604 + den.hosts.x86_64-linux.igloo.users.tux = { }; 605 + 606 + # If conf owns the option, host imports conf, and therefore 607 + # host.hasAspect is defined. The smoke test would fail first 608 + # if not — this test documents the contract explicitly. 609 + expr = den.schema ? conf; 610 + expected = true; 611 + } 612 + ); 613 + 614 + # ─── Group I: error cases ───────────────────────────────────────── 615 + 616 + test-I-bad-ref-throws = denTest ( 617 + { den, ... }: 618 + let 619 + result = builtins.tryEval (den.hosts.x86_64-linux.igloo.hasAspect "not-a-ref"); 620 + in 621 + { 622 + den.hosts.x86_64-linux.igloo.users.tux = { }; 623 + den.aspects.igloo.nixos = { }; 624 + 625 + expr = result.success; 626 + expected = false; 627 + } 628 + ); 629 + 630 + # ─── Group J: real-world class-body call (cycle-safety check) ───── 631 + 632 + # The primary intended use case: calling hasAspect from inside a 633 + # deferred nixos module body. The body runs at evalModules time, 634 + # long after the aspect tree is frozen — a cyclic implementation 635 + # would hit infinite recursion here. 636 + # 637 + # Note: the `host` specialArg set by nix/lib/types.nix lives on 638 + # the den host submodule and does NOT propagate into OS-level 639 + # deferred nixos modules. We close over the entity at the outer 640 + # level via a let binding — the nixos body is still deferred, so 641 + # the cycle-safety property is still validated. 642 + test-J-hasAspect-in-class-module-body = denTest ( 643 + { den, igloo, ... }: 644 + let 645 + hostEntity = den.hosts.x86_64-linux.igloo; 646 + in 647 + { 648 + den.hosts.x86_64-linux.igloo.users.tux = { }; 649 + 650 + den.aspects.igloo.includes = [ 651 + den.aspects.feature-flag 652 + den.aspects.gated-consumer 653 + ]; 654 + den.aspects.feature-flag.nixos = { }; 655 + 656 + den.aspects.gated-consumer.nixos = 657 + { config, ... }: 658 + { 659 + environment.variables.HAS_FLAG = 660 + if hostEntity.hasAspect den.aspects.feature-flag then "yes" else "no"; 661 + }; 662 + 663 + expr = igloo.environment.variables.HAS_FLAG or null; 664 + expected = "yes"; 665 + } 666 + ); 667 + 668 + }; 669 + }
+162
templates/ci/modules/features/one-of-aspects.nix
··· 1 + # Tests for den.lib.aspects.adapters.oneOfAspects — the structural- 2 + # decision adapter for "prefer A over B when both are present". 3 + { denTest, lib, ... }: 4 + { 5 + flake.tests.one-of-aspects = { 6 + 7 + test-prefers-first-present = denTest ( 8 + { den, trace, ... }: 9 + { 10 + den.aspects.bundle.includes = [ 11 + den.aspects.pref-a 12 + den.aspects.pref-b 13 + ]; 14 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 15 + den.aspects.pref-a 16 + den.aspects.pref-b 17 + ]; 18 + den.aspects.pref-a.nixos = { }; 19 + den.aspects.pref-b.nixos = { }; 20 + 21 + # Tombstone visible in trace as ~pref-b. 22 + expr = trace "nixos" den.aspects.bundle; 23 + expected.trace = [ 24 + "bundle" 25 + [ "pref-a" ] 26 + [ "~pref-b" ] 27 + ]; 28 + } 29 + ); 30 + 31 + test-falls-through-to-second = denTest ( 32 + { den, trace, ... }: 33 + { 34 + den.aspects.bundle.includes = [ den.aspects.only-b ]; 35 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 36 + den.aspects.pref-a 37 + den.aspects.only-b 38 + ]; 39 + # pref-a is defined but not included in the bundle subtree. 40 + den.aspects.pref-a.nixos = { }; 41 + den.aspects.only-b.nixos = { }; 42 + 43 + expr = trace "nixos" den.aspects.bundle; 44 + expected.trace = [ 45 + "bundle" 46 + [ "only-b" ] 47 + ]; 48 + } 49 + ); 50 + 51 + test-both-absent-no-effect = denTest ( 52 + { den, trace, ... }: 53 + { 54 + den.aspects.bundle.includes = [ den.aspects.neither ]; 55 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 56 + den.aspects.pref-a 57 + den.aspects.pref-b 58 + ]; 59 + den.aspects.neither.nixos = { }; 60 + # pref-a and pref-b are defined but not included: 61 + den.aspects.pref-a.nixos = { }; 62 + den.aspects.pref-b.nixos = { }; 63 + 64 + # No tombstones — neither candidate is in the subtree. 65 + expr = trace "nixos" den.aspects.bundle; 66 + expected.trace = [ 67 + "bundle" 68 + [ "neither" ] 69 + ]; 70 + } 71 + ); 72 + 73 + test-composes-with-outer-adapter = denTest ( 74 + { den, trace, ... }: 75 + { 76 + # root sibling-filters bundle and sibling; bundle internally 77 + # uses oneOfAspects. Verifies the two adapters both take effect 78 + # at their own level without interfering: root's filter kills 79 + # the sibling, bundle's oneOfAspects kills pref-b. 80 + den.aspects.root.includes = [ 81 + den.aspects.bundle 82 + den.aspects.sibling 83 + ]; 84 + den.aspects.root.meta.adapter = 85 + inherited: den.lib.aspects.adapters.excludeAspect den.aspects.sibling inherited; 86 + den.aspects.bundle.includes = [ 87 + den.aspects.pref-a 88 + den.aspects.pref-b 89 + ]; 90 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 91 + den.aspects.pref-a 92 + den.aspects.pref-b 93 + ]; 94 + den.aspects.pref-a.nixos = { }; 95 + den.aspects.pref-b.nixos = { }; 96 + den.aspects.sibling.nixos = { }; 97 + 98 + expr = trace "nixos" den.aspects.root; 99 + expected.trace = [ 100 + "root" 101 + [ 102 + "bundle" 103 + [ "pref-a" ] 104 + [ "~pref-b" ] 105 + ] 106 + [ "~sibling" ] 107 + ]; 108 + } 109 + ); 110 + 111 + test-works-on-sub-aspects = denTest ( 112 + { den, trace, ... }: 113 + { 114 + den.aspects.bundle.includes = [ 115 + den.aspects.foo._.impl-a 116 + den.aspects.foo._.impl-b 117 + ]; 118 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 119 + den.aspects.foo._.impl-a 120 + den.aspects.foo._.impl-b 121 + ]; 122 + den.aspects.foo._.impl-a.nixos = { }; 123 + den.aspects.foo._.impl-b.nixos = { }; 124 + 125 + expr = trace "nixos" den.aspects.bundle; 126 + expected.trace = [ 127 + "bundle" 128 + [ "impl-a" ] 129 + [ "~impl-b" ] 130 + ]; 131 + } 132 + ); 133 + 134 + test-preserves-non-candidate-includes = denTest ( 135 + { den, trace, ... }: 136 + { 137 + den.aspects.bundle.includes = [ 138 + den.aspects.pref-a 139 + den.aspects.pref-b 140 + den.aspects.unrelated 141 + ]; 142 + den.aspects.bundle.meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 143 + den.aspects.pref-a 144 + den.aspects.pref-b 145 + ]; 146 + den.aspects.pref-a.nixos = { }; 147 + den.aspects.pref-b.nixos = { }; 148 + den.aspects.unrelated.nixos = { }; 149 + 150 + # unrelated is not a candidate and should be untouched. 151 + expr = trace "nixos" den.aspects.bundle; 152 + expected.trace = [ 153 + "bundle" 154 + [ "pref-a" ] 155 + [ "~pref-b" ] 156 + [ "unrelated" ] 157 + ]; 158 + } 159 + ); 160 + 161 + }; 162 + }
+149
templates/example/modules/aspects/hasAspect-examples.nix
··· 1 + # Worked examples for `host.hasAspect` and `oneOfAspects`. 2 + # 3 + # Two complementary tools for two different jobs: 4 + # 5 + # - `entity.hasAspect` READS structure at query time from inside 6 + # class-config module bodies (`nixos = ...`, `homeManager = ...`). 7 + # Cycle-safe because the body runs at evalModules time, long after 8 + # the aspect tree has been resolved and frozen. 9 + # 10 + # - `oneOfAspects` (and friends in `nix/lib/aspects/adapters.nix`) 11 + # WRITE structure at adapter time with full structural visibility. 12 + # The right tool for "prefer A over B when both are present" and 13 + # other tree-shape decisions. 14 + # 15 + # These illustrative aspects are all `example-` prefixed to make it 16 + # clear they are pedagogical stubs and to avoid colliding with any 17 + # real aspect names in the template. 18 + { den, lib, ... }: 19 + { 20 + 21 + # ────────────────────────────────────────────────────────────────── 22 + # Pattern 1 — Reading structure via `host.hasAspect` from a class body 23 + # ────────────────────────────────────────────────────────────────── 24 + # 25 + # The 95% case. A parametric aspect captures `host` from its functor 26 + # arguments and uses `host.hasAspect` inside its `nixos = ...` body 27 + # to branch on whether a companion aspect is structurally present on 28 + # the same host. 29 + # 30 + # This is cycle-safe: the `nixos` body is a deferred module that 31 + # runs during evalModules, by which point `host.resolved` (the 32 + # aspect tree this query reads) has already been computed and is 33 + # frozen. There is no back-edge from the body into the tree. 34 + 35 + # Two "backend" aspects an impermanence setup can be flavored against. 36 + den.aspects.example-zfs-root.nixos = { 37 + # Stub representing the zfs-root configuration that would set up 38 + # zpools, datasets, the boot loader, etc. Kept empty here so the 39 + # example template still evaluates without zfs-specific options. 40 + environment.etc."example-root-backend".text = "zfs"; 41 + }; 42 + 43 + den.aspects.example-btrfs-root.nixos = { 44 + environment.etc."example-root-backend".text = "btrfs"; 45 + }; 46 + 47 + # An impermanence aspect that adapts its config based on which root 48 + # backend is also present on the host. The outer `{ host, ... }:` 49 + # makes the aspect parametric, which is what gives the inner `nixos` 50 + # body access to `host.hasAspect`. 51 + den.aspects.example-impermanence = 52 + { host, ... }: 53 + { 54 + nixos = 55 + { lib, ... }: 56 + lib.mkMerge [ 57 + (lib.mkIf (host.hasAspect den.aspects.example-zfs-root) { 58 + # zfs-flavored impermanence wiring would go here 59 + # (e.g. rollback service on a zfs snapshot of the root dataset). 60 + environment.etc."example-impermanence-flavor".text = "zfs"; 61 + }) 62 + (lib.mkIf (host.hasAspect den.aspects.example-btrfs-root) { 63 + # btrfs-flavored impermanence wiring would go here 64 + # (e.g. snapshot rollback via btrfs subvolumes). 65 + environment.etc."example-impermanence-flavor".text = "btrfs"; 66 + }) 67 + ]; 68 + }; 69 + 70 + # ────────────────────────────────────────────────────────────────── 71 + # Pattern 2 — Deciding structure via a `meta.adapter` (`oneOfAspects`) 72 + # ────────────────────────────────────────────────────────────────── 73 + # 74 + # When the decision is STRUCTURAL ("which of these aspects should 75 + # actually be part of the tree?") rather than CONFIGURATIONAL ("given 76 + # this aspect is in the tree, how should it configure NixOS?"), the 77 + # right tool is a `meta.adapter` composed via `oneOfAspects`. 78 + # 79 + # The adapter runs during the resolve tree walk and has full 80 + # structural visibility — and crucially, it operates ON the tree 81 + # rather than reading FROM INSIDE it, so it can't cycle. 82 + # 83 + # Here: a secrets-bundle aspect lists both an agenix-rekey-style 84 + # provider and a sops-nix-style provider in its includes, and a 85 + # `oneOfAspects` adapter prefers agenix-rekey when both are present 86 + # and falls back to sops-nix otherwise. Both candidates remain in 87 + # the includes list — `oneOfAspects` tombstones the loser during 88 + # resolution rather than at module-definition time. 89 + 90 + den.aspects.example-agenix-rekey.nixos = { 91 + environment.etc."example-secrets-provider".text = "agenix-rekey"; 92 + }; 93 + 94 + den.aspects.example-sops-nix.nixos = { 95 + environment.etc."example-secrets-provider".text = "sops-nix"; 96 + }; 97 + 98 + den.aspects.example-secrets-bundle = { 99 + includes = [ 100 + den.aspects.example-agenix-rekey 101 + den.aspects.example-sops-nix 102 + ]; 103 + meta.adapter = den.lib.aspects.adapters.oneOfAspects [ 104 + den.aspects.example-agenix-rekey # preferred when present 105 + den.aspects.example-sops-nix # fallback 106 + ]; 107 + }; 108 + 109 + # ────────────────────────────────────────────────────────────────── 110 + # Anti-pattern — DO NOT use `hasAspect` to decide an `includes` list 111 + # ────────────────────────────────────────────────────────────────── 112 + # 113 + # The following shape looks plausible but produces an infinite 114 + # recursion at evaluation time: 115 + # 116 + # den.aspects.broken = 117 + # { host, ... }: 118 + # { 119 + # includes = 120 + # if host.hasAspect den.aspects.example-zfs-root 121 + # then [ den.aspects.example-zfs-impermanence ] 122 + # else [ den.aspects.example-btrfs-impermanence ]; 123 + # }; 124 + # 125 + # Why it cycles: 126 + # 127 + # - `host.hasAspect` queries the resolved aspect tree. 128 + # - The resolved aspect tree depends on every aspect's `includes`. 129 + # - `broken`'s `includes` depends on `host.hasAspect`. 130 + # 131 + # That is a back-edge into the tree from a position the tree itself 132 + # has to read first to know its own shape — a classic fixed-point 133 + # with no fixed point. Nix evaluation reports infinite recursion. 134 + # 135 + # The correct tool for "decide what to include based on what else 136 + # is structurally present" is a `meta.adapter`. See 137 + # `nix/lib/aspects/adapters.nix` for the full set: 138 + # 139 + # - `oneOfAspects [ a b c ]` keep the first present, tombstone the rest 140 + # - `excludeAspect <ref>` tombstone a specific aspect by reference 141 + # - `substituteAspect <a> <b>` swap one aspect for another 142 + # - `filter` / `filterIncludes` custom filtering primitives 143 + # 144 + # These run during the tree walk with full structural visibility and 145 + # operate ON the tree rather than FROM INSIDE it, so they can't 146 + # cycle. Reach for them whenever the decision you want to make is 147 + # "which aspects should be in the tree?" rather than "given this 148 + # aspect is in the tree, what should it configure?" 149 + }