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

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:

```sh
navree check
```

It does two things, in order:

1. It validates the module graph with the binary's built-in `check`, which constructs nothing. A broken graph stops here.
2. It runs `conformity:check`, which boots the app once without serving traffic and runs every conformity check the app's modules contribute. `ApiModule` and `WorkspaceModule` contribute theirs; the checks never run on the request path.

```text
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 warning
```

Errors 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:

```json
{
  "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](https://navree.com/docs/digging-deeper/openapi.md) with no summary, no tags and no schemas, which is what typed clients and your colleagues read.

```rust
#[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:

```toml title="config/app.toml"
[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:

```rust title="src/modules/deals/mod.rs"
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](https://navree.com/docs/workspaces/isolation.md#records-checked-by-route) and classifies it.
- `workspace_entity::<E>()` classifies an entity that isn't an access subject. Its entity implements `WorkspaceScoped`.
- `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_many` and `delete_by_id` on 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:

```rust
// 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](https://navree.com/docs/testing/isolation-tests.md) 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_id` by 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](https://navree.com/docs/workspaces/isolation.md#row-level-security-on-postgresql) 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.translations` runs `i18n:check`, which the [localization](https://navree.com/docs/packages/localization.md) module registers to catch syntax errors and messages missing from a locale.
- `db.migrations` runs `migrate: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:

```rust
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 billing` selects them.
- `Finding::error` fails the check, `Finding::warning` reports only; `.hint(..)` says how to fix it.
- `CheckOutcome::skipped(reason)` reports a check that doesn't apply. An `Err` from 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:

```rust
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:

```text
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:

```text
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:

```yaml title=".github/workflows/ci.yml"
- run: navree gate --skip web   # the front end has its own job
```

With [Dock](https://navree.com/docs/tools/dock.md), the gate's `cargo` steps get the app's service settings, as `navree dev` does.

- [Isolation tests](https://navree.com/docs/testing/isolation-tests.md): Cross-workspace tests generated from a module's routes.
- [The navree CLI](https://navree.com/docs/digging-deeper/cli.md): Every command of the CLI and of your app.
