Skip to content
Riot Docs

Search is only available in production builds. Try building and previewing the site to test it out locally.

Install Riot GitHub

RFD0013 - Riot Fix Package-Provided Rules

  • Feature Name: riot_fix_package_rules
  • Start Date: 2026-03-20
  • Status: implemented

This RFD extends riot-fix so workspace packages can ship their own lint rules and explanations, with those rules compiled at build time into one synthetic fixme-runner.

The central design should be:

  • do not run one binary per rule
  • do not run one binary per package
  • do parse each file once
  • do run all enabled rules in-process

The resulting system should have these properties:

  • packages should declare at most one [riot.fix.provider]
  • provider source should default to either:
    • fix/riot_fix_rules/riot_fix_rules.ml
    • fix/riot_fix_rules.ml
    • and may accept these as compatibility fallbacks:
      • src/riot_fix_rules/riot_fix_rules.ml
      • src/riot_fix_rules.ml
  • rule ids should be automatically namespaced as <package>:<rule>
  • diagnostic codes should be automatically namespaced as <package>:<code>
  • riot fix should generate a fixme-runner under _build
  • that runner should rebuild like any other riot package and use normal caching

std:no-stdlib should be a good first concrete package-owned rule using this model.

riot-fix already had the right local primitives:

  • parser-backed analysis
  • typed diagnostics
  • fixes and explanations
  • one-process execution

What it lacked was ownership. Rules such as no-stdlib are not really generic riot-fix opinions. They are package-owned policies. std should own std:no-stdlib in the same way suri, sqlx, or minttea should eventually own their own lint rules.

There is also a hard runtime constraint. Riot-sized workspaces are already large enough that per-rule or per-package subprocess execution would be the wrong shape. The cost is not just CPU time; it is repeated parsing, process startup, transport overhead, and poor streaming behavior.

So the requirement is:

riot-fix must let packages own rules without giving up a generated in-process fixme-runner.

A package can expose a fix provider in riot.toml:

[riot.fix.provider]
rules = ["no-stdlib"]

If path is omitted, riot-fix should probe these defaults in order, preferring build-only fix/ locations:

  • fix/riot_fix_rules/riot_fix_rules.ml
  • fix/riot_fix_rules.ml
  • src/riot_fix_rules/riot_fix_rules.ml
  • src/riot_fix_rules.ml

The provider source should not be compiled as part of the owning package’s normal runtime build. Instead, riot fix should discover all providers in the workspace, generate a synthetic fixme-runner package, build it, and run that one binary.

From the user side, the command surface stays simple:

riot fix
riot fix --check
riot fix --explain std:f0001

From the runtime side, the shape is:

flowchart TD
A[riot fix] --> B[load workspace]
B --> C[discover providers]
C --> D[generate fixme-runner]
D --> E[build fixme-runner]
E --> F[parse each file once]
F --> G[run all enabled rules in-process]

The manifest shape should be:

[riot.fix.provider]
path = "fix/riot_fix_rules.ml" # optional
rules = ["no-stdlib"]

Rules should be declared provider-locally, but exposed to users as <package>:<rule>.

For example:

  • package std
  • local rule no-stdlib

becomes:

  • std:no-stdlib

Likewise, provider-defined diagnostic code f0001 should become std:f0001.

Shared rule-authoring types should live in fixme.

Provider implementations should prefer fix/ over src/ so build-only rule code does not participate in the package’s runtime dependency graph. Legacy src/ discovery may exist as a compatibility fallback, but it should not be the preferred authoring location.

The generated fixme-runner should still expose the richer Riot_fix runtime surface, but provider authors should write against the shared rule API plus syn helpers.

Provider-owning packages should place rule-authoring dependencies such as fixme in [build-dependencies], not [dependencies].

Conceptually, a provider module looks like:

open Std
let name = "std"
let rules () =
[ No_stdlib.make () ]
let diagnostic_codes () =
No_stdlib.codes

Provider support modules that live next to the entrypoint should be copied into the generated fixme-runner as sibling embedded modules.

riot fix should generate a workspace-specific synthetic package that depends on:

  • riot-fix
  • fixme
  • syn
  • std
  • each provider-owning package

and emits generated source that:

  • embeds provider entrypoints
  • embeds provider support modules
  • registers providers before CLI execution

The resulting fixme-runner should be rebuilt like any other riot package, so it inherits normal build caching behavior.

Per file, the fixme-runner should:

  1. parse once with syn
  2. run all enabled built-in and package rules in-process
  3. collect diagnostics and optional fixes
  4. stream results through the CLI reporter

That runtime model is the main reason to prefer generated fusion over provider subprocesses.

The effective rule set should be:

  1. built-in rules
  2. discovered package-provided rules
  3. workspace [riot.fix].rules overrides
  4. package-local [riot.fix].rules overrides

Short rule syntax still applies:

  • "name" enables
  • "-name" disables

Package-local overrides apply on top of workspace defaults.

riot fix --explain std:f0001 searches:

  1. built-in diagnostic codes
  2. package-owned diagnostic codes fused into the runtime
  3. unknown-code fallback if nothing matches

That should make package-provided explanations first-class.

std wants to own std:no-stdlib, but direct normal dependency layering like:

  • std -> fixme
  • fixme -> syn
  • syn -> ceibo
  • ceibo -> std

creates a cycle.

That is why provider source must remain outside the owning package’s normal build, and why provider authoring dependencies should resolve through [build-dependencies], not through the runtime package graph.

Package-provided rules should integrate with dependency classes like this:

  • normal package builds should not traverse provider build-time dependencies
  • generated fused tooling such as riot fix should resolve provider packages through the Build graph
  • provider authors should be able to depend on fixme, syn, and other rule-authoring support libraries without contaminating runtime package products
  • packages should still own their rule ids and diagnostic code namespaces even when those rules are compiled only into the fused runtime
  • fusion introduces generated workspace build artifacts
  • provider source is compiled in a synthetic runtime, not in the owning package’s normal build
  • the synthetic runtime must be rebuilt when provider membership changes
  • provider authoring pushes on the package dependency model, which is why dependency classes are necessary

Because it is the wrong runtime shape:

  • too many subprocesses
  • repeated parsing
  • repeated startup cost
  • poor scaling for large workspaces
Section titled “Why not statically link all rules into checked-in riot-fix”

Because it creates the wrong ownership model:

  • package-specific rules stay centralized
  • package authors must edit packages/riot-fix
  • riot-fix becomes a bottleneck package

Because it buys the wrong tradeoff:

  • platform-sensitive behavior
  • ABI fragility
  • more complex debugging
  • harder-to-reason-about build/runtime story

Generated fusion is more explicit and more predictable.

Relevant patterns:

  • synthetic registries generated at build time
  • workspace-discovered extension points
  • toolchains that keep extension ownership at the package boundary while still executing one coherent runtime

This design follows that same pattern.

  • How far should provider sources be allowed to reach into the owning package: only public modules, or some future friend/internal surface?
  • When built-ins migrate to package providers, should the built-in copies linger for a transition window or be removed immediately after verification?