diff --git a/nix/lib/aspects/fx/handlers/compile.nix b/nix/lib/aspects/fx/handlers/compile.nix index c452746d..b6e06f14 100644 --- a/nix/lib/aspects/fx/handlers/compile.nix +++ b/nix/lib/aspects/fx/handlers/compile.nix @@ -2,29 +2,88 @@ # Shape router — dispatches to compile-* based on aspect shape. { den, + lib, ... }: let inherit (den.lib) fx; + inherit (den.lib.aspects.fx) argClass; + inherit (den.lib.aspects.fx.contentUtil) unwrapContentValuesList; + inherit (den.lib.schemaUtil) schemaEntityKindsSet; + schema = den.schema or { }; + classReg = den.classes or { }; + + # Entity kinds NAMED as function args by a static aspect's class-content + # modules, unioned across every class key. Mirrors emit-classes.nix + # `namedEntityArgs` (bare-fn modules only) but does NOT gate on ctx — the + # router decides fan-out eligibility via the schema DAG (see below). + namedClassEntityKinds = + aspect: + let + classKeys = builtins.filter (k: classReg ? ${k}) (builtins.attrNames aspect); + kindsIn = + module: + if builtins.isFunction module then + builtins.filter (a: schemaEntityKindsSet ? ${a}) (builtins.attrNames (builtins.functionArgs module)) + else + [ ]; + in + lib.unique ( + builtins.concatMap (k: builtins.concatMap kindsIn (unwrapContentValuesList aspect.${k})) classKeys + ); in { compileHandler = { "compile" = { param, state }: let - meta = param.aspect.meta or { }; + aspect = param.aspect; + meta = aspect.meta or { }; + isStatic = + !(meta ? __forward) && !(meta ? guard) && !(aspect ? __fn) && (aspect.__args or { }) == { }; + + # A class-content module naming a strict DESCENDANT of the emitting scope's + # kind (host-scope aspect with `nixos = { user, ... }:`) has no such binding + # in scope, so wrapClassModule would drop it silently. Promote the aspect to + # be parametric on those kinds: the bind handler then fans it per descendant + # instance via the SAME machinery the aspect-level `{ user, ... }:` form uses, + # making the two forms equivalent. Descendant classification (not mere + # ctx-absence) keeps scope-kind-self and ancestor args on the static path — + # bind would only satisfy or inert those. `__parametricResolvedArgs` excludes + # kinds already bound by an earlier fan, so the re-resolved body (still naming + # the kind) does not re-promote into a loop. + scopeKind = ((state.scopeEntityKind or (_: { })) null).${state.currentScope or ""} or null; + resolvedArgs = aspect.__parametricResolvedArgs or [ ]; + promoteKinds = + if isStatic && scopeKind != null then + builtins.filter ( + k: (argClass.isDescendantOf schema scopeKind k) && !(builtins.elem k resolvedArgs) + ) (namedClassEntityKinds aspect) + else + [ ]; + promoted = + if promoteKinds != [ ] then + aspect + // { + __fn = _: aspect; + __args = lib.genAttrs promoteKinds (_: false); + __functor = self: self.__fn; + } + else + aspect; + effect = if meta ? __forward then "compile-forward" else if meta ? guard then "compile-conditional" - else if param.aspect ? __fn || (param.aspect.__args or { }) != { } then + else if promoted ? __fn || (promoted.__args or { }) != { } then "compile-parametric" else "compile-static"; in { - resume = fx.send effect param; + resume = fx.send effect (param // { aspect = promoted; }); inherit state; }; }; diff --git a/templates/ci/modules/features/deadbugs/issue-629-class-module-user-arg.nix b/templates/ci/modules/features/deadbugs/issue-629-class-module-user-arg.nix new file mode 100644 index 00000000..a2959868 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/issue-629-class-module-user-arg.nix @@ -0,0 +1,54 @@ +# Issue #629: requesting `user` inside an aspect's `nixos` class module silently +# drops the module when the aspect is included at HOST scope. +# +# Reporter includes an aspect at host scope whose `nixos` class module names +# `user`. At host scope the emit ctx has `host` but no `user`, so wrapClassModule +# marks it `unsatisfied` and wrap-classes drops it (silently — the lib.warn is +# attached to the discarded module and never forced). The aspect-level parametric +# form `{ user, ... }: { nixos = ...; }` works because the bind handler fans the +# aspect over host.users; class-module args never reach that fan-out. +{ denTest, ... }: +{ + flake.tests.deadbugs.issue-629-class-module-user-arg = { + + # FAILING: class module names `user`, aspect included at host scope. + # Host has one user; the module should fan per-user like the aspect form. + test-class-module-user-arg-at-host-scope = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.desktop.cosmic.nixos = + { user, ... }: + { + environment.etc."cosmic-autologin".text = user.userName; + }; + + den.aspects.igloo.includes = [ den.aspects.desktop.cosmic ]; + + expr = igloo.environment.etc."cosmic-autologin".text or ""; + expected = "tux"; + } + ); + + # CONTROL: aspect-level parametric form of the same thing — already works. + test-aspect-level-parametric-at-host-scope = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.desktop.cosmic = + { user, ... }: + { + nixos.environment.etc."cosmic-autologin".text = user.userName; + }; + + den.aspects.igloo.includes = [ den.aspects.desktop.cosmic ]; + + expr = igloo.environment.etc."cosmic-autologin".text or ""; + expected = "tux"; + } + ); + + }; +}