From cdc95cee02a9d1f824684bf1844d12aa4d9a3ba6 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 17 Jul 2026 16:40:54 -0700 Subject: [PATCH] fix: fan class-module entity args over scope descendants (#629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nix/lib/aspects/fx/handlers/compile.nix | 65 ++++++++++++++++++- .../issue-629-class-module-user-arg.nix | 54 +++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/issue-629-class-module-user-arg.nix diff --git a/nix/lib/aspects/fx/handlers/compile.nix b/nix/lib/aspects/fx/handlers/compile.nix index c452746dc..b6e06f145 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 000000000..a29598683 --- /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"; + } + ); + + }; +}