Reports ALLM.Pipeline.Schema's narrow nilability rule across every compiled
schema module in the current project.
mix allm_pipeline.nilability [--report]--report is the default and only mode: the task never edits a file, and
every valid invocation exits 0. An unrecognised switch or a stray
positional argument is refused with Mix.raise/1 — before subphase 2.4's
follow-up the parse discarded all three of its elements, so
mix allm_pipeline.nilability --reprot exited 0 with a full report and a typo
was indistinguishable from a request.
The rule
A field's generated @type t entry gains | nil iff it has neither
required: true nor a non-nil default:, its declared type does not already
end in | nil, and it carries no explicit nilable: option. nilable: true
forces | nil; nilable: false forbids it.
Note that default: nil is not a default for this rule, while
default: false is one — the producers test != nil, and false != nil.
What "pending" means
A field is pending when the rule says its generated type should be
nilable-tailed and __allm_schema__(:generated_types) says it is not. Before
the rule was applied in the macro, every rule-firing field was pending; after,
none is. That is what lets one task serve as both the prediction and the
verification of the change — and it is why the declared (:types) /
generated (:generated_types) split exists. A diff of :types across that
change is empty for every field, because the rule rewrites no source.
Why this file re-implements the rule instead of calling the macro's copy
nilable_tail?/1 below is byte-identical to
ALLM.Pipeline.Schema's private copy, and categorize/4 +
rule_says_nilable?/2 mirror its generated_type/4 + nilable?/3. A third
copy lives in scripts/nilability_predict.py, and a fourth — of
nilable_tail?/1 only — is ALLM.Pipeline.Schema.JsonSchema.strip_nil/1,
which reads the same right spine to decide whether a derived JSON property
gets its ["string", "null"] union. This duplication is
deliberate and must not be deduplicated: this task's job is to be able to
falsify the macro, so if it delegated to the macro's helper, "0 pending"
would be the macro asked whether it agrees with itself — criterion 2 would
verify nothing and the invariant root CLAUDE.md tells operators to trust
would be tautological.
Per the house rule "A rule enforced in more than one shape needs
a MEMBERSHIP guard", a deliberate mirror still has to be declared (this
section, and the matching comment above Schema.nilable_tail?/1) and
pinned. The pin is the "drift guard" describe in
test/mix/tasks/allm_pipeline_nilability_test.exs, which runs both
implementations over one shared fixture and asserts they agree field for
field — catching drift without either implementation calling the other. The
same describe carries the strip_nil/1 parity test, over a fixture written in
mappable types (Applied's map() / term() / two-member union have no
strict-mode JSON rendering, so the derivation cannot read that one).
Why the predicate is __allm_schema__/1
Not __schema__/1. Every Ecto.Schema module exports __schema__/1 too, and
__schema__(:types) returns Ecto types rather than declared ASTs — so that
predicate would enumerate every Ecto schema in the project and read garbage
out of it. The same collision is why ALLM.Pipeline.StepLog's serializer keys
on the distinct name.
Known blind spot: nilable: false
__allm_schema__(:nilable) lists fields declared nilable: true only —
its contract is [atom()], which cannot express false — so this task cannot
see a nilable: false declaration and would report such a field as pending
forever.
There are zero nilable: declarations in the lib/ trees this task can
enumerate. The tree does carry six, all .exs test fixtures in this
package (schema_test.exs ×5, this task's own test ×1), and none is reachable
by schema_modules/0 — it reads Application.spec(app, :modules), which is
built from lib/ only. So the 0 pending result is exact rather than lucky.
Re-derive with a NUL-safe sweep that sees both extensions (2026-08-14):
python3 scripts/refsweep.py 'field\(.*nilable:' apps \
--include '*.ex' --include '*.exs' --format hits(This paragraph previously claimed zero declarations tree-wide and cited
`grep -rna "nilable:" apps/ --include=.ex, which structurally cannot see the six.exs` ones. The conclusion held; the evidence was narrower than the
claim.)*
If a nilable: false field is ever declared in lib/, the honest fix is to
widen the introspection contract, not to special-case it here.
Summary
Types
Why a field does or does not fire the rule. Mirrors
scripts/nilability_predict.py's categories, and with the same precedence, so
the two instruments' totals are directly comparable.
One field's verdict: its category, and whether the rule is unapplied.
Functions
One verdict per declared field of module, in declaration order.
Whether a quoted type AST already ends in | nil.
Every loaded module exporting __allm_schema__/1, in sorted order.
Types
@type category() :: :required | :default | :already_nilable | :bare
Why a field does or does not fire the rule. Mirrors
scripts/nilability_predict.py's categories, and with the same precedence, so
the two instruments' totals are directly comparable.
@type field_report() :: %{ field: atom(), category: category(), explicit_nilable: boolean(), pending: boolean() }
One field's verdict: its category, and whether the rule is unapplied.
Functions
@spec field_reports(module()) :: [field_report()]
One verdict per declared field of module, in declaration order.
Whether a quoted type AST already ends in | nil.
A union nests to the RIGHT — a | b | nil is
{:|, _, [a, {:|, _, [b, nil]}]} — so this walks the right spine looking for
a literal nil. A leading nil | a is deliberately not a nilable tail:
the rule is about the tail, and treating it otherwise would make the detection
depend on where in a union the author happened to write it.
@spec schema_modules() :: [module()]
Every loaded module exporting __allm_schema__/1, in sorted order.
Enumerated through Application.loaded_applications/0 and
Application.spec/2, so this package names no host application.