Conformity checks
navree check verifies what the compiler can't: every route documented, every entity classified, workspace data reached only through WorkspaceRepo, translations and migrations. navree gate runs it with fmt, clippy, the tests and the front end in one command.
On this page
The compiler checks types and the container checks the module graph, but some rules of a well-built app live between modules: every route documented in the API, every table either owned by a workspace or shared, no query on workspace data without the workspace filter. navree check verifies those rules in one command, and navree gate runs it together with formatting, lints, tests and the front end's checks before you push.
Running navree check
Run it in your app:
navree checkIt does two things, in order:
- It validates the module graph with the binary's built-in
check, which constructs nothing. A broken graph stops here. - It runs
conformity:check, which boots the app once without serving traffic and runs every conformity check the app's modules contribute.ApiModuleandWorkspaceModulecontribute theirs; the checks never run on the request path.
module graph OK: 21 modules, 96 providers
Conformity checks
ok api.docs Every route is documented with #[api] (38 checked)
FAIL workspace.entities Every entity is classified as a workspace or a global entity (5 checked)
error src/modules/deals/note.rs:6: entity `deal_notes` is not classified as a workspace or a global entity
hint: register it in its module: m.workspace_entity::<note::Entity>() (its rows belong to one workspace) or m.global_entity::<note::Entity>() (shared by every workspace)
FAIL workspace.repo Workspace entities are reached only through WorkspaceRepo (41 checked)
error src/modules/deals/service.rs:58: reaches workspace entity `deals` directly with `entity::Entity::find_by_id`
hint: go through WorkspaceRepo::<entity::Entity> (select, find, insert, update, delete), or mark a deliberate cross-workspace access with // navree:allow(workspace-repo)
warn workspace.tables Workspace tables have an indexed workspace column (3 checked)
warning deals: no index starts with `workspace_id`
hint: every workspace query filters by workspace_id: create an index on (workspace_id) or one that starts with it
skip i18n.translations Every message is translated in every locale: the app has no `i18n:check` command (the localization module registers it)
skip db.migrations Pending migrations pass the migration lint: the app has no `migrate:lint` command
2 errors, 1 warningErrors fail the command (exit status 1); warnings are reported only. Because conformity:check boots the app, it needs the same configuration and database as any other command (navree migrate first, so the tables exist).
| Flag | Does |
|---|---|
--only api.docs,workspace | Runs only the named checks. A name without a dot selects its whole section: workspace runs workspace.entities, workspace.repo and workspace.tables. |
--json | Prints the report as JSON, for CI annotations and agents. |
--graph | Prints the module tree (every module, provider, export and command) and stops. |
The JSON report lists each check with its status (ok, warn, fail or skip), how many items it looked at, and its findings:
{
"checks": [
{
"name": "api.docs",
"about": "Every route is documented with #[api]",
"status": "fail",
"checked": 38,
"findings": [
{
"severity": "error",
"subject": "GET /api/v1/deals/board",
"message": "has no #[api] documentation (module DealsModule)",
"hint": "add #[api(summary = \"...\")] to the handler (or `.api(ApiMeta { .. })` to the RouteDef), or list the route under api.undocumented"
}
]
}
],
"errors": 1,
"warnings": 0
}You can run the app command directly too, which is what a deployment pipeline does: cargo run -- conformity:check --root . --json. --root is where the source checks read your code; navree check passes the app's directory.
The checks
| Check | Contributed by | Verifies |
|---|---|---|
api.docs | ApiModule | Every route has #[api] documentation |
workspace.entities | WorkspaceModule | Every entity in your source is classified as a workspace or a global entity |
workspace.repo | WorkspaceModule | Code reaches workspace entities only through WorkspaceRepo |
workspace.tables | WorkspaceModule | Each workspace table has its workspace column, an index that starts with it and, on PostgreSQL, row-level security |
i18n.translations | Built in | Runs the app's i18n:check command when a module registers it |
db.migrations | Built in | Runs the app's migrate:lint command when a module registers it |
Routes documented
api.docs lists every route without #[api] metadata, except the API module's own openapi.json and docs. The framework's routes (auth, access, audit, workspaces, notifications and the rest) are documented, so the findings are yours. A route without docs appears in the OpenAPI document with no summary, no tags and no schemas, which is what typed clients and your colleagues read.
#[get("/board")]
#[permissions("deals.read")]
#[api(summary = "The pipeline board", tag = "Deals", response = Board)]
async fn board(&self, uow: Uow) -> Result<Json<Board>> {
// ...
}Two keys of the api section go with it:
[api]
require_docs = true # an undocumented route stops the boot
undocumented = ["POST /api/hooks/*"] # routes allowed without docs| Key | Default | Does |
|---|---|---|
api.require_docs | false | Refuses to boot while a route has no #[api], listing each one: api.require_docs: 2 routes have no #[api] documentation: GET /api/v1/deals/board (module DealsModule), .... Turn it on in development so a new route can't ship undocumented. |
api.undocumented | [] | Routes allowed without docs, by mounted path: "METHOD /path" or "/path" for every method, and a trailing * matches the rest of the path. From the environment, comma-separated: APP_API__UNDOCUMENTED=GET /internal/*. |
Apps made with navree new list the Lighthouse and Logbook dashboards there (undocumented = ["/_lighthouse*", "/_logbook*"]): they are pages, not API routes. Other modules serve pages or speak their own protocol too; list them when you add them to the app:
| Module | Routes | Pattern |
|---|---|---|
| Helm | the admin panel, its assets and the JSON calls behind it | "/admin*" (or your helm.path) |
| Fleet | the dashboard and the JSON calls behind it | "/_fleet*" (or your fleet.path) |
| GraphQL | the endpoint, GraphiQL and the subscriptions socket | "/graphql*" (or your graphql.path) |
| UI | the component assets and the gallery | "/_navree/ui*" (or your ui.mount) |
navree check names the module of every undocumented route, so a module missing from the list shows up the first time it runs.
Entities classified
Under row isolation, every table either belongs to one workspace or is shared by all of them, and the difference decides how code may read it. workspace.entities reads your source for SeaORM entities (#[sea_orm(table_name = "..")]) and requires each one to be classified in its module:
impl DealsModule {
fn wire(&self, m: &mut ModuleBuilder) {
m.workspace_scoped::<Deal>() // a workspace entity that routes check by id
.workspace_entity::<DealNote>() // a workspace entity
.global_entity::<Currency>(); // one set of rows for every workspace
}
}workspace_scoped::<E>()registers an access subject for the record checks and classifies it.workspace_entity::<E>()classifies an entity that isn't an access subject. Its entity implementsWorkspaceScoped.global_entity::<E>()marks a table shared by every workspace: currencies, plans, the app's own settings.
Entities defined by navree crates (users, workspaces, jobs) aren't in your source and aren't checked. An entity classified both ways is an error too.
Workspace data through WorkspaceRepo
workspace.repo finds the places where your code reaches a workspace entity directly, where the workspace filter is easy to forget:
Entity::find,find_by_id,insert,insert_many,update,update_many,delete,delete_manyanddelete_by_idon a workspace entity;Repo::<Entity>, navree-db's repository, which doesn't know about workspaces.
WorkspaceRepo::<Entity> and CurrentWorkspace::scope(..) are the scoped ways in. A deliberate cross-workspace access, such as an admin report or a maintenance command, takes a marker comment on its line or the line above:
// Every workspace's open deals, for the platform dashboard. navree:allow(workspace-repo)
let open = deal::Entity::find().filter(deal::Column::Stage.ne("won")).all(conn).await?;The scan reads your code as text, not as a compiled program. It follows use declarations (trees, self, super, crate, as renames and globs) and type aliases, so use super::entity::{self, Entity as Deal} and then Deal::find() is found, while another module's entity::Entity is not confused with this one. It skips tests/, benches/, examples/ and #[cfg(test)] modules. It can't see through a generic function that takes the entity as a type parameter, or a macro that writes the query; the isolation tests catch what reaches the routes either way.
Workspace tables
workspace.tables reads the migrated database. For each workspace entity's table:
- the workspace column (
WorkspaceScoped::WORKSPACE_COLUMN,workspace_idby default) must exist, or it is an error; - an index should start with that column, since every workspace query filters by it (a warning);
- on PostgreSQL, row-level security should be enabled and forced (a warning).
A table that doesn't exist yet is a warning: run your migrations first. The check reads SQLite and PostgreSQL.
Under schema and database isolation each workspace's tables are apart already, so the three workspace checks are skipped.
Translations and migrations
Two checks run commands other modules register, so navree check covers them without knowing their modules:
i18n.translationsrunsi18n:check, which the localization module registers to catch syntax errors and messages missing from a locale.db.migrationsrunsmigrate:lint, the migration lint, when a module registers it.
Each is skipped, with the reason, when the app has no such command.
Adding your own check
A module contributes a check with conformity_check. The closure gets the booted app and the source root, and returns what it looked at and what it found:
use navree::contracts::{CheckOutcome, ConformityCheck, Finding, ModuleBuilderConformityExt};
use navree::kernel::{Module, ModuleBuilder};
/// The plans the app sells (a provider of the billing module).
pub struct PlanCatalog {
pub plans: Vec<Plan>,
}
pub struct Plan {
pub key: String,
pub price_cents: Option<u64>,
}
pub struct BillingModule;
impl Module for BillingModule {
fn register(&self, m: &mut ModuleBuilder) {
m.conformity_check(ConformityCheck::new(
"billing.plans",
"Every plan has a price",
|cx| async move {
let catalog = cx.app().get::<PlanCatalog>().await?;
let findings: Vec<Finding> = catalog
.plans
.iter()
.filter(|plan| plan.price_cents.is_none())
.map(|plan| {
Finding::error(format!("plan `{}`", plan.key), "has no price")
.hint(format!("set billing.plans.{}.price_cents", plan.key))
})
.collect();
Ok(CheckOutcome::checked(catalog.plans.len()).findings(findings))
},
));
}
}- Name checks
<section>.<what>, so--only billingselects them. Finding::errorfails the check,Finding::warningreports only;.hint(..)says how to fix it.CheckOutcome::skipped(reason)reports a check that doesn't apply. AnErrfrom the closure is reported as an error finding: "the check could not run: ...".cx.root()is the app's source directory, for checks that read files.
conformity_check also imports ConformityModule, which registers the conformity:check command once, however many modules contribute checks. ConformityReport::run(ctx, &ConformityOptions::new(root)) runs the checks in a test:
use navree::contracts::{ConformityOptions, ConformityReport};
#[tokio::test]
async fn the_app_passes_its_conformity_checks() {
let app = app().await;
let report = ConformityReport::run(app.ctx(), &ConformityOptions::new(env!("CARGO_MANIFEST_DIR"))).await;
assert!(report.is_ok(), "{report}");
}The gate
navree gate runs every check a change must pass before you push, one step after another, and stops at the first failure:
| Step | Runs | In |
|---|---|---|
fmt | cargo fmt --all -- --check | the app |
clippy | cargo clippy --workspace --all-targets -- -D warnings | the app |
test | cargo test --workspace | the app |
check | navree check | the app |
web:typecheck | the typecheck script, or tsc --noEmit when the front end has a tsconfig.json and no such script | web/ |
web:test | the test script | web/ |
web:build | the build script | web/ |
--workspace is passed when your Cargo.toml has a [workspace] table (a navree new app does). The web steps run when web/package.json exists, with pnpm, yarn or npm chosen by the lockfile, and only for the scripts it has. Each step's output streams to your terminal, then the gate prints its times:
Gate
ok fmt 0.6s cargo fmt --all -- --check
ok clippy 41.3s cargo clippy --workspace --all-targets -- -D warnings
ok test 1m 12s cargo test --workspace
ok check 3.4s navree check
ok web:typecheck 9.8s npm run typecheck
ok web:test 14.1s npm run test
ok web:build 52.7s npm run build
gate passed in 3m 14s| Flag | Does |
|---|---|
--only test,check | Runs only these steps. web selects every web: step. |
--skip web | Leaves steps out. |
--keep-going | Runs every step even after one fails, to see all the failures at once. |
--web frontend | The front end's directory, relative to the app (default web/). |
--no-lock | Doesn't wait for other gates. |
One gate at a time
A gate compiles and tests the whole app, and two at once on one machine slow each other down and can exhaust its memory. So the gate takes a lock, navree-gate.lock in the system's temporary directory, shared by every app of the same user on the machine. A second gate waits and says whose gate it waits for:
Waiting for another navree gate on this machine: process 18244 in /home/ada/acme (lock: /tmp/navree-gate.lock)...The lock is released when the gate ends, including when it fails or you stop it. In CI, where each job has its own machine, the lock costs nothing.
In CI
The gate is one command, so a pipeline step is one line:
- run: navree gate --skip web # the front end has its own jobWith Dock, the gate's cargo steps get the app's service settings, as navree dev does.