mix allm_pipeline.nilability (allm_pipeline v0.1.0)

Copy Markdown View Source

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

category()

@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.

field_report()

@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

field_reports(module)

@spec field_reports(module()) :: [field_report()]

One verdict per declared field of module, in declaration order.

nilable_tail?(arg1)

@spec nilable_tail?(Macro.t()) :: boolean()

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.

schema_modules()

@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.