Skip to content

Commit cdc95ce

Browse files
committed
fix: fan class-module entity args over scope descendants (#629)
A static aspect whose class-content module names a strict descendant of the emitting scope's entity kind (e.g. `nixos = { user, ... }:` at host scope) had no such binding in scope, so wrapClassModule marked it unsatisfied and wrap-classes dropped it silently — while the equivalent aspect-level `{ user, ... }:` form worked, because bind fans that over the scope's descendants. Promote such aspects to be parametric on the named descendant kinds in the compile shape router, so the bind handler fans them per descendant instance via the SAME machinery the aspect-level form uses, making the two equivalent. Descendant classification (not mere ctx-absence) keeps scope-kind-self and ancestor args on the static path; the __parametricResolvedArgs guard stops the re-resolved body from re-promoting into a loop.
1 parent 2fcac84 commit cdc95ce

2 files changed

Lines changed: 116 additions & 3 deletions

File tree

nix/lib/aspects/fx/handlers/compile.nix

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,88 @@
22
# Shape router — dispatches to compile-* based on aspect shape.
33
{
44
den,
5+
lib,
56
...
67
}:
78
let
89
inherit (den.lib) fx;
10+
inherit (den.lib.aspects.fx) argClass;
11+
inherit (den.lib.aspects.fx.contentUtil) unwrapContentValuesList;
12+
inherit (den.lib.schemaUtil) schemaEntityKindsSet;
13+
schema = den.schema or { };
14+
classReg = den.classes or { };
15+
16+
# Entity kinds NAMED as function args by a static aspect's class-content
17+
# modules, unioned across every class key. Mirrors emit-classes.nix
18+
# `namedEntityArgs` (bare-fn modules only) but does NOT gate on ctx — the
19+
# router decides fan-out eligibility via the schema DAG (see below).
20+
namedClassEntityKinds =
21+
aspect:
22+
let
23+
classKeys = builtins.filter (k: classReg ? ${k}) (builtins.attrNames aspect);
24+
kindsIn =
25+
module:
26+
if builtins.isFunction module then
27+
builtins.filter (a: schemaEntityKindsSet ? ${a}) (builtins.attrNames (builtins.functionArgs module))
28+
else
29+
[ ];
30+
in
31+
lib.unique (
32+
builtins.concatMap (k: builtins.concatMap kindsIn (unwrapContentValuesList aspect.${k})) classKeys
33+
);
934
in
1035
{
1136
compileHandler = {
1237
"compile" =
1338
{ param, state }:
1439
let
15-
meta = param.aspect.meta or { };
40+
aspect = param.aspect;
41+
meta = aspect.meta or { };
42+
isStatic =
43+
!(meta ? __forward) && !(meta ? guard) && !(aspect ? __fn) && (aspect.__args or { }) == { };
44+
45+
# A class-content module naming a strict DESCENDANT of the emitting scope's
46+
# kind (host-scope aspect with `nixos = { user, ... }:`) has no such binding
47+
# in scope, so wrapClassModule would drop it silently. Promote the aspect to
48+
# be parametric on those kinds: the bind handler then fans it per descendant
49+
# instance via the SAME machinery the aspect-level `{ user, ... }:` form uses,
50+
# making the two forms equivalent. Descendant classification (not mere
51+
# ctx-absence) keeps scope-kind-self and ancestor args on the static path —
52+
# bind would only satisfy or inert those. `__parametricResolvedArgs` excludes
53+
# kinds already bound by an earlier fan, so the re-resolved body (still naming
54+
# the kind) does not re-promote into a loop.
55+
scopeKind = ((state.scopeEntityKind or (_: { })) null).${state.currentScope or ""} or null;
56+
resolvedArgs = aspect.__parametricResolvedArgs or [ ];
57+
promoteKinds =
58+
if isStatic && scopeKind != null then
59+
builtins.filter (
60+
k: (argClass.isDescendantOf schema scopeKind k) && !(builtins.elem k resolvedArgs)
61+
) (namedClassEntityKinds aspect)
62+
else
63+
[ ];
64+
promoted =
65+
if promoteKinds != [ ] then
66+
aspect
67+
// {
68+
__fn = _: aspect;
69+
__args = lib.genAttrs promoteKinds (_: false);
70+
__functor = self: self.__fn;
71+
}
72+
else
73+
aspect;
74+
1675
effect =
1776
if meta ? __forward then
1877
"compile-forward"
1978
else if meta ? guard then
2079
"compile-conditional"
21-
else if param.aspect ? __fn || (param.aspect.__args or { }) != { } then
80+
else if promoted ? __fn || (promoted.__args or { }) != { } then
2281
"compile-parametric"
2382
else
2483
"compile-static";
2584
in
2685
{
27-
resume = fx.send effect param;
86+
resume = fx.send effect (param // { aspect = promoted; });
2887
inherit state;
2988
};
3089
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Issue #629: requesting `user` inside an aspect's `nixos` class module silently
2+
# drops the module when the aspect is included at HOST scope.
3+
#
4+
# Reporter includes an aspect at host scope whose `nixos` class module names
5+
# `user`. At host scope the emit ctx has `host` but no `user`, so wrapClassModule
6+
# marks it `unsatisfied` and wrap-classes drops it (silently — the lib.warn is
7+
# attached to the discarded module and never forced). The aspect-level parametric
8+
# form `{ user, ... }: { nixos = ...; }` works because the bind handler fans the
9+
# aspect over host.users; class-module args never reach that fan-out.
10+
{ denTest, ... }:
11+
{
12+
flake.tests.deadbugs.issue-629-class-module-user-arg = {
13+
14+
# FAILING: class module names `user`, aspect included at host scope.
15+
# Host has one user; the module should fan per-user like the aspect form.
16+
test-class-module-user-arg-at-host-scope = denTest (
17+
{ den, igloo, ... }:
18+
{
19+
den.hosts.x86_64-linux.igloo.users.tux = { };
20+
21+
den.aspects.desktop.cosmic.nixos =
22+
{ user, ... }:
23+
{
24+
environment.etc."cosmic-autologin".text = user.userName;
25+
};
26+
27+
den.aspects.igloo.includes = [ den.aspects.desktop.cosmic ];
28+
29+
expr = igloo.environment.etc."cosmic-autologin".text or "<skipped>";
30+
expected = "tux";
31+
}
32+
);
33+
34+
# CONTROL: aspect-level parametric form of the same thing — already works.
35+
test-aspect-level-parametric-at-host-scope = denTest (
36+
{ den, igloo, ... }:
37+
{
38+
den.hosts.x86_64-linux.igloo.users.tux = { };
39+
40+
den.aspects.desktop.cosmic =
41+
{ user, ... }:
42+
{
43+
nixos.environment.etc."cosmic-autologin".text = user.userName;
44+
};
45+
46+
den.aspects.igloo.includes = [ den.aspects.desktop.cosmic ];
47+
48+
expr = igloo.environment.etc."cosmic-autologin".text or "<skipped>";
49+
expected = "tux";
50+
}
51+
);
52+
53+
};
54+
}

0 commit comments

Comments
 (0)