RFD0013 - Riot Fix Package-Provided Rules
- Feature Name:
riot_fix_package_rules - Start Date:
2026-03-20 - Status:
implemented
Summary
Section titled “Summary”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.mlfix/riot_fix_rules.ml- and may accept these as compatibility fallbacks:
src/riot_fix_rules/riot_fix_rules.mlsrc/riot_fix_rules.ml
- rule ids should be automatically namespaced as
<package>:<rule> - diagnostic codes should be automatically namespaced as
<package>:<code> riot fixshould generate afixme-runnerunder_build- that runner should rebuild like any other
riotpackage and use normal caching
std:no-stdlib should be a good first concrete package-owned rule using this
model.
Motivation
Section titled “Motivation”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.
Guide-level explanation
Section titled “Guide-level explanation”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.mlfix/riot_fix_rules.mlsrc/riot_fix_rules/riot_fix_rules.mlsrc/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 fixriot fix --checkriot fix --explain std:f0001From 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]Reference-level explanation
Section titled “Reference-level explanation”1. Manifest shape
Section titled “1. Manifest shape”The manifest shape should be:
[riot.fix.provider]path = "fix/riot_fix_rules.ml" # optionalrules = ["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.
2. Provider authoring
Section titled “2. Provider authoring”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.codesProvider support modules that live next to the entrypoint should be copied into
the generated fixme-runner as sibling embedded modules.
3. Fusion model
Section titled “3. Fusion model”riot fix should generate a workspace-specific synthetic package that depends
on:
riot-fixfixmesynstd- 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.
4. Runtime model
Section titled “4. Runtime model”Per file, the fixme-runner should:
- parse once with
syn - run all enabled built-in and package rules in-process
- collect diagnostics and optional fixes
- stream results through the CLI reporter
That runtime model is the main reason to prefer generated fusion over provider subprocesses.
5. Config interaction
Section titled “5. Config interaction”The effective rule set should be:
- built-in rules
- discovered package-provided rules
- workspace
[riot.fix].rulesoverrides - package-local
[riot.fix].rulesoverrides
Short rule syntax still applies:
"name"enables"-name"disables
Package-local overrides apply on top of workspace defaults.
6. Explain flow
Section titled “6. Explain flow”riot fix --explain std:f0001 searches:
- built-in diagnostic codes
- package-owned diagnostic codes fused into the runtime
- unknown-code fallback if nothing matches
That should make package-provided explanations first-class.
7. Dependency-layering constraint
Section titled “7. Dependency-layering constraint”std wants to own std:no-stdlib, but direct normal dependency layering like:
std -> fixmefixme -> synsyn -> ceiboceibo -> 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.
8. Dependency-class interaction
Section titled “8. Dependency-class interaction”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 fixshould resolve provider packages through theBuildgraph - 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
Drawbacks
Section titled “Drawbacks”- 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
Rationale and alternatives
Section titled “Rationale and alternatives”Why not one provider binary per package
Section titled “Why not one provider binary per package”Because it is the wrong runtime shape:
- too many subprocesses
- repeated parsing
- repeated startup cost
- poor scaling for large workspaces
Why not statically link all rules into checked-in riot-fix
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-fixbecomes a bottleneck package
Why not dynlink
Section titled “Why not dynlink”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.
Prior art
Section titled “Prior art”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.
Unresolved questions
Section titled “Unresolved questions”- 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?