Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RFD 0081 — Check-pass rule registry: per-axis drivers, one auditable rule table

State: discussion

Question

The check pass wires diagnostics three ways, none of which lets a reviewer see “what runs where” in one place, and none of which enforces that every rule an enumeration axis is supposed to run actually runs on every case in that axis. Can the wiring be reorganized so that each enumeration axis has one data-driven registry — a table a reviewer reads top to bottom — without moving the gates whose placement is semantically load-bearing?

Context

Today diagnostics reach source through three distinct styles:

  1. Body-scoped syntactic rules. Rules that walk a body (a fn, mutate, check, derive, query, or test body). These are now enumerated by the body-walk driver introduced in pull request #1727, after review of that change found coverage holes: the driver replaced seam-by-seam wiring that had kept growing rule by rule as new body-bearing positions were noticed.
  2. Item-level gates. Wired per item kind by hand in the check_file_with dispatch loop (compiler/crates/oxc-check/src/infer.rs): a chain of if let Item::… = &item arms, each calling the checks that item kind needs.
  3. Inference-interleaved rules. Rules that fire inside expression inference (infer_expr and the infer_exprs family) because they need a type the walk is in the middle of computing.

Styles 2 and 3 have no enumeration. Adding a new body-bearing item kind means remembering, by reading the loop, which of the item-level and body rules it needs — and there is no test that the set is complete.

Seam-hole history (evidence)

The syntactic-rule wiring grew by accretion and repeatedly shipped incomplete:

  • Pull request #1701 wired body rules seam by seam. Review found three coverage holes — rules that ran on some body-bearing positions and not others structurally identical to them.
  • Follow-up review found four more adjacent positions missing the same rules, and one double-emit (a rule wired at two seams that both fired on the same node).
  • Pull request #1727 responded with the body-walk driver (for_each_check_body
    • a BodyPlane mask) so a body rule is registered once and the driver visits every body. That closed the body axis but left the item axis and the inference-coupled rules on the old hand-wired styles.

The pattern is structural, not a run of bad luck: hand-enumerated seams have no exactly-once invariant, so every new body-bearing item kind must independently re-derive the full rule set, and the failure mode (a hole) is silent — a rule that should refuse simply does not, and nothing reddens.

Decision

Introduce one rule registry per enumeration axis. A registry is a table of entries; each entry is data:

(codes, axis, mask, fn, wired)
  • codes — the diagnostic code(s) the rule can emit (for audit / cross-index).
  • axis — which enumeration axis the entry belongs to (BODY | ITEM).
  • mask — the plane/kind mask selecting which cases in the axis the rule runs on (BodyPlane for body rules; an ItemKind mask for item rules).
  • fn — the rule function.
  • wireddriver (the axis driver enumerates and invokes it) or inline (the rule is invoked at a hand-placed call site and the row is an INVENTORY entry, present for auditability only — not driver-dispatched).

The table is the single source of truth for where a rule runs. A reviewer reads one table per axis; a new item kind gets every driver-wired rule whose mask admits it, automatically.

Axis 1 — BODY (exists)

The body-walk driver from pull request #1727 (for_each_check_body + BodyPlane) IS the axis-1 registry instance. This RFD adopts it as such: body rules become registry entries with axis: BODY, mask: BodyPlane, wired: driver. No new mechanism for axis 1 — this RFD only names it and brings it under the shared registry vocabulary and the shared invariants below.

Axis 2 — ITEM (new)

A for_each_item driver over the top-level items of a file, dispatching each registered item rule whose ItemKind mask admits the item. This replaces the hand-written if let Item::… chain in check_file_with for the rules that are mechanically item-kind-keyed. Candidate migrations, enumerated by reading check_file_with (infer.rs) and references.rs:

RuleCode(s)Current siteItem kinds
Nested-declaration refusal in standpoint bodyOE0733check_standpoint_body_withStandpointDecl
Impl-member mutate tuple-target undeclared program-wideOE0266flag_mutate_tuple_target_undeclared_everywhereImplBlock
Duplicate-import bindingOE0105check_use_imports_withuse (file-scoped)
Glob-import ambiguityOE0106report_glob_ambiguity via use walkuse (file-scoped)
Module-level mutate target gateOE0223check_mutate_with (scoped)MutateDecl
Refinement-predicate well-formednessOE0101, OE0106check_refinement_predicates + type_check_refinement_predicatesitems carrying refinements
Signature checks (procmacro)OE0727, OE0729, OE0734infer_signature via check_fn_withFnDecl

The file-scoped use rules run once over all imports rather than per item; they enter the registry with a file-scoped mask, dispatched once by the driver — not per-item — so their exactly-once guarantee is over the file, not over each use.

INVENTORY rows (wired: inline)

The following rules stay exactly where they are and enter the table only as wired: inline inventory rows. Each carries one line stating why its placement is semantic and cannot become driver-dispatched:

RuleCode(s)Why inline
Emit-value typingOE0201Needs the value’s inferred type mid-inference; fires inside the emit-consequence inference path, not at item entry.
Collection-operand guard (issue #186)Fires at the operand’s inference point; the offending operand’s type is only known there.
Update-set RHS typingOE0201The set RHS is type-checked against the target column during mutate-body inference; no item-entry point sees the resolved column type.
Elaborator gates (e.g. trait invocation-plane member with body)OE1326Enforced in oxc-instantiate lowering, a different layer; the refusal precedes lowering and cannot move into the check walk.
Runtime backstops (e.g. program-global tuple-target resolution)OE0266Deliberately mirrors the runtime resolver’s program-global match; some cases are legal at runtime and must not be refused statically.

Inventory rows make style-3 and cross-layer gates VISIBLE in the same table without pretending they are mechanically enumerable. They are audited, not migrated.

Invariants

  • Exactly-once per axis. Every case in an axis is visited exactly once by the driver, and every driver rule whose mask admits a case runs on it exactly once. Tested: no hole (a mask-admitted case with the rule not run) and no double-emit (the same rule firing twice on one node).
  • Plane/kind dispatch. A rule runs iff its mask admits the case. Masks are the only selection mechanism; there is no hand-placed conditional inside the driver.
  • Registration is the single source of where a rule runs. For driver rules, the row’s mask fully determines the run set; for inline rows, the row records the hand-placed site. Either way the table answers “where does this run”.
  • Byte-identical behavior per migration wave. Each wave that moves a rule from hand-wiring to the registry must leave the emitted diagnostic set unchanged over the full pin corpus — the migration is a refactor, not a semantics change.

Rationale

  • The seam-hole history shows hand-enumerated wiring fails silently and repeatedly; the fix is to make the enumeration a machine-checked property, not reviewer diligence.
  • Pull request #1727 already proved the pattern on the body axis. Generalizing to a per-axis registry is a small, evidence-backed step, not a speculative redesign.
  • Keeping inference-coupled and cross-layer gates as inventory rows preserves the one property that matters for review — a single readable table — without forcing rules into a driver that cannot correctly host them.

Alternatives

  • Keep hand-wiring, add more tests. Rejected: tests over hand-enumerated seams still require someone to write the test for each new seam; the exactly-once property is not structural.
  • One universal driver over all rules including inference-coupled ones. Rejected: inference-coupled rules need types produced mid-walk; hoisting them to item entry either recomputes inference or changes semantics (see the OE0106-refinement regression in Non-goals).
  • Migrate cross-layer gates into the check pass. Rejected: placement of elaborator and runtime gates is semantic (they guard lowering and runtime resolution respectively); moving them changes when and against what they fire.

Non-goals

  • Migrating inference-interleaved rules. Hooks-in-inference is explicitly out of scope. Cautionary case: making infer_struct_lit type-only (dropping its interleaved refinement work) regressed the OE0106 refinement check — the refinement diagnostic depended on state the literal inference computed in place. That is the evidence that these rules cannot be lifted to a driver without a semantics change; they stay inline and enter the table as inventory.
  • Moving cross-layer gates. Elaborator (oxc-instantiate) and runtime backstop gates stay in their layers; they are inventoried, not relocated.

Consequences (phasing)

  • Wave 1: land the registry table type + the item-axis driver (for_each_item + ItemKind mask); migrate 2–3 mechanical item gates (candidates: OE0733 standpoint nested-decl, OE0266 impl-member mutate target, OE0103/OE0105 use-import resolution/duplicate). The OE0106 glob-ambiguity emitter, first listed here as a wave-1 candidate, is reclassified to a wired: inline inventory row: report_glob_ambiguity fires at resolved use sites across reference resolution and expression checks (type slots, predicates, callee paths, struct-literal heads), so no item-entry driver can preserve its placement. All INVENTORY rows land in wave 1 so the table is complete-as-audit from the start even though most rows are inline.
  • Wave 2: migrate the remaining mechanical item gates (module-level mutate target OE0223, refinement well-formedness, signature checks) as each is shown to be item-kind-keyed and inference-free.
  • Body axis needs no migration wave — it is adopted as-is from pull request #1727.

Verification (per wave)

  • All existing diagnostic pins unchanged (byte-identical emitted set over the pin corpus).
  • Exactly-once tests: no mask-admitted case skipped, no double-emit.
  • Plane/kind mutation checks: unregister a rule (or narrow its mask) → exactly that mask’s pins redden, and no others. This proves the mask is the true and complete selector.
  • Cross-model review of each wave’s diff against the byte-identical-behavior contract.

Open questions

  • Whether the file-scoped use rules warrant a distinct axis (FILE) rather than a file-scoped mask on the ITEM axis.
  • Whether codes on each entry should be validated against the diagnostic registry so an added rule with an undocumented code fails a gate.
  • Whether wave 2’s signature and refinement gates are cleanly item-kind-keyed or carry enough inference coupling to become inventory rows instead.