Skip to content

BUG: aspect being called as if it was a module-function#408

Closed
vic wants to merge 1 commit intomainfrom
push-wosuyplooklq
Closed

BUG: aspect being called as if it was a module-function#408
vic wants to merge 1 commit intomainfrom
push-wosuyplooklq

Conversation

@vic
Copy link
Copy Markdown
Owner

@vic vic commented Apr 8, 2026

EDIT: we need a new CI action that runs bogus when it detect we change a .nix file on templates/bogus. Instead of having to approve this for templates to run at CI. #246

Fails with

       error: attribute 'host' missing
       at /nix/store/2n2i983z59kjmw707xpddjdywyk8vn2g-source/lib/modules.nix:726:13:
          725|             "noting that argument `${name}` is not externally provided, so querying `_module.args` instead, requiring `config`"
          726|             config._module.args.${name}
             |             ^
          727|           )

@vic vic added the allow-ci allow all CI integration tests label Apr 8, 2026
@vic vic changed the title BUG BUG: aspect being called as if it was a module-function Apr 8, 2026
@sini
Copy link
Copy Markdown
Collaborator

sini commented Apr 8, 2026

Looking into this, I'm reproducing this on my end as well.

@sini
Copy link
Copy Markdown
Collaborator

sini commented Apr 8, 2026

I have the root cause and a fix, working on the cleanest possible version of the fix.

sini added a commit that referenced this pull request Apr 8, 2026
Return { includes = [fn] } instead of a bare function, matching the
POC pattern. The function result lives in includes where
parametric.applyIncludes handles it with withIdentity, preserving the
aspect name through resolution. Fixes #408.
sini added a commit that referenced this pull request Apr 8, 2026
Return { includes = [fn] } instead of a bare function, matching the
POC pattern. The function result lives in includes where
parametric.applyIncludes handles it with withIdentity, preserving the
aspect name through resolution. Fixes #408.
sini added a commit to sini/den that referenced this pull request Apr 9, 2026
Bare function aspects (e.g., { host, ... }: { nixos... }) merged with
static config from separate modules now work correctly. Fixes vic#408.

Three changes:

- take.nix: carryAttrs preserves name through successful take calls
- types.nix: coercedTo in aspectsType wraps non-module functions into
  { includes = [fn] } so they aren't treated as module functions
- perHost-perUser.nix: returns { includes = [fn] } matching the POC
  pattern, so parametric.applyIncludes handles identity via withIdentity
vic pushed a commit that referenced this pull request Apr 9, 2026
Return { includes = [fn] } instead of a bare function, matching the POC
pattern. The function result lives in includes where
parametric.applyIncludes handles it with withIdentity, preserving the
aspect name through resolution. Fixes #408.
@vic vic closed this in #410 Apr 9, 2026
sini added a commit to sini/den that referenced this pull request Apr 11, 2026
PR vic#410 added coercedProviderType to make `{host, ...}: {nixos = ...}`
aspects merge with sibling static `.nixos = ...` defs (fixes vic#408). The
predicate was too broad: any non-module function got wrapped into
`{ includes = [fn] }`, which silently broke "factory" aspects like:

    den.aspects.facter = reportPath: { nixos = ...; };
    den.aspects.igloo.includes = [ (den.aspects.facter "/path") ];

Coercing turns den.aspects.facter into a full aspect with the default
functor. Calling `(facter "/path")` invokes the default functor in a
non-static branch with the string as `ctx` — the user's `reportPath`
is discarded and the config body never materializes.

Narrow the predicate with `lib.functionArgs v != {}`. Context fns have
destructured named args (non-empty functionArgs) and still get coerced.
Factory fns with a bare positional arg stay typed as providerFnType,
whose merge wraps the underlying function via
`__functor = _: eth.merge loc defs` so `(aspect arg)` correctly
dispatches to the user function.

Bisected to d266c3a (PR vic#410). Minimal repro verified broken at that
commit and working at v0.15.0. Fix passes the new deadbugs test plus
all 278 existing CI tests including deadbugs-issue-408 (context-fn
+ static merge still works).

Fixes vic#429.
vic pushed a commit that referenced this pull request Apr 11, 2026
PR #410 added coercedProviderType to make `{host, ...}: {nixos = ...}`
aspects merge with sibling static `.nixos = ...` defs (fixes #408). The
predicate was too broad: any non-module function got wrapped into `{
includes = [fn] }`, which silently broke "factory" aspects like:

    den.aspects.facter = reportPath: { nixos = ...; };
    den.aspects.igloo.includes = [ (den.aspects.facter "/path") ];

Coercing turns den.aspects.facter into a full aspect with the default
functor. Calling `(facter "/path")` invokes the default functor in a
non-static branch with the string as `ctx` — the user's `reportPath` is
discarded and the config body never materializes.

Narrow the predicate with `lib.functionArgs v != {}`. Context fns have
destructured named args (non-empty functionArgs) and still get coerced.
Factory fns with a bare positional arg stay typed as providerFnType,
whose merge wraps the underlying function via
`__functor = _: eth.merge loc defs` so `(aspect arg)` correctly
dispatches to the user function.

Bisected to d266c3a (PR #410). Minimal repro verified broken at that
commit and working at v0.15.0. Fix passes the new deadbugs test plus all
278 existing CI tests including deadbugs-issue-408 (`context-fn +
static` merge still works).

Fixes #429.
sini added a commit to sini/den that referenced this pull request Apr 11, 2026
Extends has-aspect.nix with the complete Groups A-J test matrix from
the design spec §7.3:

- Group A: sanity / transitive chains
- Group B: parametric contexts, including the vic#423 and vic#413 regression
  shapes as explicit lock-ins
- Group C: factory-function aspects (§8.1 resolved as Outcome A —
  factory-path identity is stable; tested via direct factory reference
  in includes since invoked instances become anonymous aspects)
- Group D: provider sub-aspects + identity disambiguation
- Group E: mutual-provider / provides chains (to-users, per-user,
  to-hosts)
- Group F: substituteAspect, composition at different levels (NOT
  nested-reaching per filterIncludes.tag semantics), oneOfAspects
  integration
- Group G: multi-class users (primary, forClass, forAnyClass, unknown
  class)
- Group H: extensibility contract (den.schema.conf owns the option)
- Group I: error cases — bad ref throws
- Group J: real-world call from inside a deferred nixos module body,
  validating the cycle-safety guarantee for the primary intended use
  case (uses outer let-binding closure capture since the `host`
  specialArg from nix/lib/types.nix:30 lives on the den host submodule
  and does not propagate into OS-level deferred nixos modules)

hasAspect reads config.resolved which is the output of the ctxApply +
parametric resolution pipeline. Every aspect-shape recent regressions
(vic#408, vic#413, vic#423, vic#429) lived in has a corresponding test here, so a
future regression in parametric.nix or aspects/types.nix that affects
any of these shapes fails a hasAspect test before it reaches user code.

Two tests in Groups B and D are XFAILed against current behavior:
function-bodied provider sub-aspects (`foo._.sub = { host, ... }: ...`)
lose their `foo` provider prefix in the resolved tree (aspectPath
becomes ["sub"] instead of ["foo","sub"]). The class config still
materializes correctly — see deadbugs/issue-413 — but hasAspect lookup
misses because the reference's path differs from the resolved tree's
path. Test bodies document the gap and are wired to flip to `expected
= true` once the parametric pipeline preserves provider prefix on
function-bodied sub-aspects.
sini added a commit to sini/den that referenced this pull request Apr 12, 2026
Extends the smoke suite with the complete regression-class matrix,
organized by aspect shape:

- Group A: sanity / transitive chains
- Group B: parametric contexts, incl. vic#423 static-sub-in-parametric-
  parent and vic#413 bare-function-sub-aspect shapes as explicit
  regression lock-ins
- Group C: factory-function aspects — direct factory reference in
  includes (factory-path identity is stable; inline invocation
  produces a differently-named sibling, tested here as a caveat)
- Group D: provider sub-aspects + identity disambiguation
- Group E: mutual-provider / provides chains (to-users, per-user,
  to-hosts)
- Group F: substituteAspect, composition at different levels (NOT
  nested-reaching per filterIncludes.tag semantics), oneOfAspects
  integration
- Group G: multi-class users (primary, forClass, forAnyClass,
  unknown class)
- Group H: extensibility contract — den.schema.conf owns the option
- Group I: error cases — bad ref throws
- Group J: real-world call from inside a deferred nixos module body,
  validating the cycle-safety guarantee for the primary intended
  use case

hasAspect reads config.resolved — the output of the ctxApply +
parametric resolution pipeline. Every aspect-shape that recently
produced a regression (vic#408, vic#413, vic#423, vic#429) has a lock-in test
here, so a future regression in parametric.nix or aspects/types.nix
that affects any of those shapes fails a hasAspect test before it
reaches user code.
@vic vic deleted the push-wosuyplooklq branch April 13, 2026 00:39
sini added a commit to sini/den that referenced this pull request Apr 13, 2026
Extends the smoke suite with the complete regression-class matrix,
organized by aspect shape:

- Group A: sanity / transitive chains
- Group B: parametric contexts, incl. vic#423 static-sub-in-parametric-
  parent and vic#413 bare-function-sub-aspect shapes as explicit
  regression lock-ins
- Group C: factory-function aspects — direct factory reference in
  includes (factory-path identity is stable; inline invocation
  produces a differently-named sibling, tested here as a caveat)
- Group D: provider sub-aspects + identity disambiguation
- Group E: mutual-provider / provides chains (to-users, per-user,
  to-hosts)
- Group F: substituteAspect, composition at different levels (NOT
  nested-reaching per filterIncludes.tag semantics), oneOfAspects
  integration
- Group G: multi-class users (primary, forClass, forAnyClass,
  unknown class)
- Group H: extensibility contract — den.schema.conf owns the option
- Group I: error cases — bad ref throws
- Group J: real-world call from inside a deferred nixos module body,
  validating the cycle-safety guarantee for the primary intended
  use case

hasAspect reads config.resolved — the output of the ctxApply +
parametric resolution pipeline. Every aspect-shape that recently
produced a regression (vic#408, vic#413, vic#423, vic#429) has a lock-in test
here, so a future regression in parametric.nix or aspects/types.nix
that affects any of those shapes fails a hasAspect test before it
reaches user code.
sini added a commit that referenced this pull request Apr 13, 2026
# 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-ci allow all CI integration tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants