# Checking your app

> Checks your app against production best-practice rules and reports where it falls short — the maturity signal, beside the correctness ones. Covers security, the tests that prove it, the data model, the cost of your queries, and what happens when a remote call fails.

<!-- id: local-checking-your-app · area: local · stability: preview · html: https://osysharp.com/reference/local/checking-your-app/ -->

## Summary        {#summary}

Reports where your app falls short of a production-grade bar. Compiling tells you the app is *correct*; `osy lint`
tells you whether it is *finished* — starting with the category that hurts most when it is not: security.

## Signature      {#signature}

```console
osy lint [path] [--json] [--strict]
```

## Description    {#description}

Findings come in three tiers:

- **MUST** — a production app is broken or exposed without it. Every rule at this tier **compiles and type-checks** —
  which is exactly why they need a linter. Most are security; the other is a number that comes back wrong.

  - **A total that is quietly short.** A function creates rows, does not commit, and then sums, averages or takes the
    min or max over that entity ([Sum / Average / Min / Max / Count](https://osysharp.com/reference/query/aggregates/)). The database computes the aggregate over **committed rows
    only**, so the answer silently omits the rows just created — it is short by exactly the work the function just did.
    Nothing fails: the code compiles, runs, and hands back a wrong number. Call `UnitOfWork.Commit();` before the aggregate.
  - **A query that filters on an edit you have not committed.** Change a property on a row, do not commit, then query
    that entity with a filter on the property you changed. The filter runs **in the database, against the committed
    value** — the one from before your edit. So the query misses the row your edit would now match, *and* still
    returns the row it no longer matches; and that row then reads back with your new value, contradicting the very
    filter that selected it. Commit before the query, or filter the rows you already hold in memory. (Reading the
    value straight off the row you edited is fine — that always shows your edit. It is only the *filter* that is
    computed on the old value, and only for a property you actually changed.)

  - **The login nobody tests.** The app has an `[AuthMethod]` and no test proves it **both ways** — that a right
    credential is accepted *and* that a wrong one is refused. Every access rule you wrote sits behind this one
    function, and it is the one that fails invisibly: a login which handed a ticket to anybody passes a suite that
    only signs in successfully, and the app behaves exactly as it does now until the wrong person is holding a
    ticket. Test the refusal too — and include an address with **no account**, which must fail exactly like a wrong
    password, or the form tells an attacker which addresses are registered.
  - **A credential is handed out.** An entity grants reads and a sensitive field (`PasswordHash`, `*Token`, `*Secret`)
    has no field-level `deny read`, so it goes to everyone who can read the row.
  - **A role grant can be written by its own subject.** A [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) table whose writes are ungated — or
    guarded by a `where` row-filter, which here says *"you may write your own privileges"* — lets a caller hand
    themselves whatever role the rest of the app trusts. Every other rule you wrote is then decoration. The same hole
    is reported when someone may `update` or `delete` a grant they could not have `create`d: editing a `Member` grant
    into an `Admin` one is the identical escalation through another verb.
  - **The auth flow is denied its own credential.** An `[AuthMethod]` runs as the ephemeral auth principal, which bears
    a role and has **no user** — working out the user is what it was called to do. An entity it must read, granted only
    by a `where` row-filter, therefore denies it: there is no user id to match, so the login cannot read the record it
    exists to check and fails every time. Your tests still pass, because they run as a real signed-in user for whom
    that filter works perfectly ([auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/)).
  - **A secret is compared with `==`.** `storedHash == Security.HashPassword(password)` is **not merely insecure — it
    can never be true**, because a hash is salted; the login cannot succeed (use `Security.VerifyPassword`,
    [Security.* — hashing, verifying, tickets, random ids](https://osysharp.com/reference/stdlib/security/)). And `==` on an HMAC tag leaks, through how long the check takes to fail, how much of a guess
    was right — enough to forge one (use `Crypto.FixedTimeEquals`, [Crypto.HmacSha256Hex and Crypto.FixedTimeEquals](https://osysharp.com/reference/function/crypto-hmac/)).
  - **An integer-only format on a Double.** `someDouble.ToString("X")` or `"D"` — those specifiers are integer-only, so
    .NET throws a `FormatException` at runtime. It compiles (`ToString` takes any string), so only the crash tells you.
    Format the Double with `N`/`F`/`C`/`P`, or convert to an integer type first.

- **SHOULD** — expected, and worth flagging.
  - **A login that says WHICH half of the credential was wrong.** Answering "no account with that address" and
    "wrong password" differently lets anybody discover which addresses are registered, without ever guessing a
    password — the input to credential-stuffing and to targeted phishing. It never looks like a security decision
    while you are writing it; it looks like a helpful error message. Answer every rejection identically, and put the
    specific message where it is safe to be specific: the sign-up page, or a reset flow that emails the address
    rather than telling the browser.
  - An entity with no `security { }` block is *safe* (deny-all means it grants nothing to anyone) but is usually a
    grant someone forgot to write — the app cannot read its own data.
  - **A credential written to the log.** `Log.*(… someHash, someToken …)` — a log line is not private (it ships to a
    sink, is retained, often indexed), and nothing redacts it for you. Log an id or an email that identifies the record,
    never the credential field itself. (Only fields *derived* as a credential or ending in `Hash`/`Token`/`Secret`/… are
    flagged — an innocently-named `TokenCount` is left alone.)
  - **An app built to be multi-user, with no way to log in.** It declares a `[Principal]`, a `[Role]` enum, and a gated
    surface — a secure-by-default page or a [role-grant](https://osysharp.com/reference/security/role-grants/) table — so it plainly means to have
    users; but not one function is an `[AuthMethod]`, so there is no login path at all. Nobody can authenticate, so
    nobody can pass the deny-all gate those pages and grants sit behind: the app compiles, its data model is complete,
    and not one real user can get in. Add a login `[AuthMethod]` and wire it in `app.AuthBootstrap`
    ([auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/)). A fully public app — no `[Principal]` — is a valid choice and is never flagged; nor
    is a `[Principal]` modelled as plain data with no roles or gated pages yet.
  - **A rule nobody ever tried to break.** An entity whose rules can deny, and no test acting as a principal proves
    they do; or an [invariant](https://osysharp.com/reference/entity/invariants/) no `Assert.Throws` proves bites. A rule you have not tested is a
    rule you only believe you wrote — and an untested invariant does not fail loudly when it rots: the day someone
    deletes it to make an import work, the suite is still green. A `[Test]` that asserts nothing at all is reported
    for the same reason.

    The two refusals are proved differently, and the difference matters. A denied **write** throws, so `Assert.Denied`
    settles it on its own. A denied **read** does not throw — row security is part of the query, so the row was never
    in the result set — and you prove it with an empty result. But an empty result is evidence of a denial *only if
    there was something there to deny*: on its own it passes just as happily when the rule refuses everyone, when the
    table is empty, or when the row was never created. So **pair it** — show that somebody *can* see a row of that
    entity, right beside the principal who cannot. Unpaired, the assertion is reported, because it does not yet prove
    what it appears to.
  - **A rule that cannot say why.** An invariant or a `[Pattern]` with no message refuses the user with the rule
    itself — and a [pattern](https://osysharp.com/reference/entity/constraints/)'s rule is a regular expression, which explains nothing.
  - **An unbounded `string`.** No `[MaxLength]` means the caller decides how much you store. Fine for prose; wrong
    for a code, a name or a status.
  - **An enum value that reads as a run-together word.** Without a `[Label]` label, an enum member is shown by its
    own name — perfect for `Draft`, and wrong the moment there are two words: the grid cell says "InProgress". Only
    multi-word members are reported; a single-word one needs no label.
  - **Work that only shows up on real data.** A query materialized with no `Take` fetches however many rows happen to
    exist; a `Skip` with no `OrderBy` lets page 2 repeat a row from page 1; reading a child collection in a loop over
    parents is one query per parent; and the same query run twice does the work twice — and may give two different
    answers. All four are correct, fast on a laptop, and the reason an app that worked in development falls over in
    production.
  - **A remote call that assumes it works.** An outbound call ([Http.*](https://osysharp.com/reference/http/facade/)) has two failure modes and they need two
    different answers. The *network* throws — a timeout, a DNS failure, a refused connection — and with no `try` that
    kills the function outright, so the caller sees an internal error instead of the failure you meant to handle. The
    *response* does not throw: a 404 or a 500 comes back as an ordinary result, so code that never looks at
    `IsSuccess` carries on and uses the error page's body as though it were the answer — wrong data, no stack trace,
    no log line.
  - **A function that reaches the network through something it calls.** The call may be nowhere in the function's own
    text — a helper makes it — and the function still has no `try`. A timeout down there kills this one exactly as
    dead. Nothing you can read in it warns you, which is the whole reason the linter looks past the source and at what
    the code actually *does*.
  - **A `catch` that says nothing.** A caught exception with no [log](https://osysharp.com/reference/diagnostics/log/) is a failure the app decided to
    survive and then forgot. In production it is invisible: no trace, no count, and no way to answer why the numbers
    are off.

- **CONSIDER** — a candidate for your judgment, reported with its evidence. Never an error.
  - A `[Unique]` or `[Pattern]` field with no `[Required]`: every constraint except `[Required]` lets null through, so
    two rows may both have no value and not collide. If the field is genuinely optional that is exactly right — and if
    you read `[Unique]` as "every row has one", it does not say that. Only you know which was meant, so the linter asks
    rather than asserts.
  - **A routed page with no title.** A page declares its name with `[Title("…")]` (the chrome/route name a breadcrumb
    reads) or a `meta { title = "…"; }` block (the SEO `<title>`). A routed page with neither is a nameless browser tab
    and an accessibility gap — a screen reader announces a page by its title on navigation. A title-less route can be
    deliberate (a redirect-only page), so it is a candidate, not an error.
  - **An editable field under a rule the browser can't pre-empt, in a form that catches nothing.** Plain field rules
    (`[Required]`/`[MaxLength]`/`[Pattern]`/…) are surfaced for you — the input carries them as native attributes, so the
    browser blocks bad input and paints the invalid state without any app code. Two rules can't work that way and still
    throw a `ValidationException` on save: a cross-field `invariant` (`Paid <= Total`), which the client can't evaluate;
    and `[Unique]`, enforced by the atomic DB index — a client "is this taken?" check *races* with the index, so the
    check is a UX nicety and the catch is the real guard. If a form edits such a field and catches no error, the user
    sees a raw failure — catch it where the form saves and show the message. If the component handles errors at all, it
    is not flagged.
  - **A class method that quietly hands off to the server.** A class method is client-runnable code, so a call it makes
    to a function or method that runs on the server is a network round trip — and nothing at the call site shows it;
    whether the callee stays on the client is a fact about *its* body, not the call. Now that almost everything runs on
    the client, a server hop is the notable exception. If it is intended, leave it; if the method was meant to stay
    client-side, keep the server-only work off its path.
  - **A number/date format that runs on the server.** `value.ToString(format[, culture])` formats in the browser only
    for the specifiers the client reproduces byte-identically; anything else fails closed to the server — a round trip,
    invisible at the call site. A Double outside `N`/`F`/`C`/`P`, a custom pattern over a Double (which also *rounds
    differently*), a runtime-built format or culture, an unsupported specifier under a culture, or an unsupported date
    format all round-trip. The finding names which, and the fix (switch the specifier, use a `Decimal`, make the format
    or culture literal). If the round trip is fine, leave it.

Each finding names the rule, what it is about, and how to close it.

`--strict` makes any MUST-tier finding fail the run, so it can gate a ship. `--json` writes the findings as JSON, for
a coding agent or a CI step.

This is a growing rule set, not a finished one — rules are added as patterns emerge. To see what the app *is* rather
than what it is missing, use [Understanding your app](https://osysharp.com/reference/local/understanding-your-app/).

### Which rules exist?   {#lint-rules}

Every rule the linter knows, from the compiler's own catalogue. This list is generated when the page is built, so
it cannot name a rule this release does not have; `osy lint --rules` prints the same list from the binary. A count
on its own would be a vanity number — the names are the point: each one says what it catches.

**A rule id is a search term.** Type the id the linter printed into `osy docs` and it answers: the page that
documents the rule, or — for a rule no page discusses on its own — this section, with the rule's own line above it
so you learn what it catches before anything opens.

⚠ The example below uses a placeholder rather than a real id ON PURPOSE — naming a specific undocumented rule here
would make THIS page the one page that mentions it, which is enough to make the lookup treat this page as the rule's
OWN documentation instead of falling through to this catalogue. That is a real trap (it happened once — a worked
example in this exact spot broke its own guard), and a placeholder cannot fall into it.

```console
$ osy docs <a rule id no page discusses on its own>
matched on lint rule <that id> (<TIER>): <what it catches> — no page documents it on its own, so the linter's
catalogue → local-checking-your-app
```

**129 rules** — 33 MUST, 75 SHOULD, 21 CONSIDER — grouped by what they judge.

**Security** · 32 rules

| Rule | Tier | Catches |
|---|---|---|
| `security-auth-role-tests-nothing` | MUST | an armed auth role that no guard anywhere ever tests |
| `security-authmethod-entity-row-filtered` | MUST | the login's own entity granted only by a row filter the auth principal cannot satisfy |
| `security-credential-mask-hides-it-from-the-login` | MUST | an unconditional deny on the credential also hides it from the login that must read it |
| `security-grant-edit-without-create` | MUST | a caller who may update or delete a role grant they could not have created |
| `security-grant-write-unguarded` | MUST | a role-grant table whose writes are open, so a caller can hand themselves a role |
| `security-hmac-compared-non-constant-time` | MUST | an HMAC tag compared with ==, which leaks how much of a guess was right |
| `security-jwt-issued-unverified` | MUST | a JWT issued before the credential was verified |
| `security-login-enumerates-users` | MUST | a login whose refusal reveals which addresses have an account |
| `security-oauth-signup-no-existing-check` | MUST | an OAuth sign-up that creates a user without checking for an existing one |
| `security-partial-exposes-platform-credential` | MUST | a partial security block on a platform entity that opens a credential column |
| `security-password-compared-directly` | MUST | a stored hash compared with == to a fresh hash, which can never be true |
| `security-password-echoed-in-clear` | MUST | a password field rendered back as visible text |
| `security-principal-credential-client-exposed` | MUST | a principal's credential field that can reach the browser |
| `security-principal-login-field-not-unique` | MUST | the field a login looks users up by is not unique |
| `security-reset-token-never-delivered` | MUST | a reset secret minted but never delivered, so nobody can finish the flow |
| `security-sensitive-field-exposed` | MUST | a readable row carries a password hash, token or secret with no field-level deny |
| `security-signup-cannot-create-the-principal` | MUST | a sign-up that runs as a principal with no create grant on the user entity |
| `security-weak-random` | MUST | a token or secret drawn from a seedable Random |
| `security-anon-page-calls-gated-function` | SHOULD | an anonymous page that calls a function anonymous callers cannot reach |
| `security-anon-page-reads-ungranted-entity` | SHOULD | an anonymous page that reads an entity nobody anonymous may read |
| `security-app-creates-what-its-block-denies` | SHOULD | a function that creates rows its entity's security block denies to every caller |
| `security-authz-without-authn` | SHOULD | a principal, roles and gated pages, but no login function at all |
| `security-callback-url-widens-an-authorize` | SHOULD | a CallbackUrl minted for an event that declares Authorize, which the link then bypasses |
| `security-classified-field-audited-unredacted` | SHOULD | a classified field written to the audit trail unredacted |
| `security-concurrency-check-without-its-trail` | SHOULD | a concurrency check on an entity whose audit trail is off |
| `security-entity-no-block` | SHOULD | an entity with no security block, which grants nobody anything — usually a forgotten grant |
| `security-integration-role-granted-by-signup-order` | SHOULD | a privileged role handed to whoever signs up first, in an app that mints per-user API keys |
| `security-login-reveals-which-credential-failed` | SHOULD | a login that answers a wrong address and a wrong password differently |
| `security-password-typed-in-clear` | SHOULD | a password bound to a plain text field instead of a password field |
| `security-secret-in-log` | SHOULD | a credential field written to the log |
| `security-signup-no-password-policy` | SHOULD | a sign-up that accepts any password at all |
| `security-auth-trail-disabled` | CONSIDER | the authentication audit trail switched off |

**Authentication** · 1 rule

| Rule | Tier | Catches |
|---|---|---|
| `auth-bootstrap-without-app-auth` | CONSIDER | an AuthBootstrap with no app.Auth, so only its own methods can sign in |

**Data model** · 14 rules

| Rule | Tier | Catches |
|---|---|---|
| `data-category-derived-from-free-text` | MUST | a category list built from the rows' own free-text values, which fragments on the first typo |
| `data-root-dialog-cannot-confirm` | MUST | a root dialog that never calls Dialog.Confirm, so nothing it edits can be saved |
| `data-unique-swapped-within-one-commit` | MUST | two rows that swap a unique value inside one commit, which the index refuses |
| `data-write-never-committed` | MUST | a write that is never committed |
| `data-caught-write-fault-left-staged` | SHOULD | a caught write fault whose failed changes are left staged instead of discarded |
| `data-detached-child-query` | SHOULD | children fetched by a standalone query instead of the parent's collection |
| `data-enum-member-no-label` | SHOULD | a multi-word enum member with no Label, shown as one run-together word |
| `data-required-without-a-message` | SHOULD | a Required field with no message for the refusal |
| `data-rule-without-message` | SHOULD | an invariant or pattern with no message, so a refusal shows the rule itself |
| `data-unbounded-string` | SHOULD | a string with no MaxLength, so the caller decides how much you store |
| `data-uniqueness-guarded-only-in-an-action` | SHOULD | uniqueness checked in an action instead of declared on the field |
| `data-constraint-lets-null-through` | CONSIDER | a Unique or Pattern field that is not Required, so null satisfies it |
| `data-row-compared-by-id` | CONSIDER | rows compared by Id by hand where == already is row identity |
| `data-unique-editable-unchecked` | CONSIDER | a Unique field edited in a form that catches no error on save |

**Correctness** · 10 rules

| Rule | Tier | Catches |
|---|---|---|
| `correctness-aggregate-over-pending-writes` | MUST | an aggregate over rows written but not yet committed, so the total is short |
| `correctness-call-has-no-sql-form` | MUST | a call inside a query with no SQL form |
| `correctness-nullable-tested-for-zero` | MUST | a nullable tested for zero, which null passes |
| `correctness-parse-that-answers-zero` | MUST | a parse that answers zero on bad input instead of failing |
| `correctness-egress-that-nothing-calls` | SHOULD | an outbound call declared that nothing in the app ever invokes |
| `correctness-external-value-replaced-by-a-literal` | SHOULD | a missing external value replaced by a literal that looks like real data |
| `correctness-freshness-stamp-with-no-source` | SHOULD | a FetchedAt-style field in an app that has no egress to have fetched anything from |
| `correctness-member-read-off-a-nullable` | SHOULD | a member read off a nullable that may be null |
| `correctness-null-substituted-for-a-value` | SHOULD | a null replaced in arithmetic by a value indistinguishable from a real one |
| `correctness-pool-slot-credited-to-its-assignee` | SHOULD | a pool slot's own arm reading its `Assignee`, which is nothing unless somebody claimed — `actor` is who acted |

**Cost** · 8 rules

| Rule | Tier | Catches |
|---|---|---|
| `cost-child-read-without-include` | SHOULD | a child collection read with no Include on the parent query |
| `cost-index-of-in-its-own-loop` | SHOULD | an IndexOf inside the loop over the same list |
| `cost-n-plus-one` | SHOULD | a child read inside a loop over parents, one query per parent |
| `cost-page-reads-the-whole-table` | SHOULD | a page that reads the whole table |
| `cost-paging-without-an-order` | SHOULD | a Skip with no OrderBy, so page 2 can repeat page 1 |
| `cost-the-same-query-twice` | SHOULD | the same query run twice in one function |
| `cost-unbounded-read` | SHOULD | a query materialized with no Take |
| `cost-clause-calls-the-server-per-element` | CONSIDER | a clause that calls the server once per element |

**Reliability** · 3 rules

| Rule | Tier | Catches |
|---|---|---|
| `reliability-http-result-unchecked` | SHOULD | an HTTP result used without checking IsSuccess |
| `reliability-outbound-call-unguarded` | SHOULD | an outbound call with no try, so a timeout kills the function or re-runs the workflow body |
| `reliability-reaches-the-network-unguarded` | SHOULD | a function or workflow body that reaches the network through a helper, with no try |

**Observability** · 1 rule

| Rule | Tier | Catches |
|---|---|---|
| `observability-catch-without-log` | SHOULD | a catch that logs nothing |

**Workflows** · 8 rules

| Rule | Tier | Catches |
|---|---|---|
| `workflow-dead-end-state` | MUST | a non-terminal state with no way out |
| `workflow-ambient-clock-in-a-durable-body` | SHOULD | the ambient clock read inside a durable body, which replays wrong |
| `workflow-clock-without-route` | SHOULD | a deadline that passes with nothing routed to happen |
| `workflow-initial-state-never-observed` | SHOULD | an Initial state whose Start body always redirects, so no run is ever in it |
| `workflow-no-success-terminal` | SHOULD | a workflow whose every ending is a cancel or an error |
| `workflow-slot-open-to-everyone` | SHOULD | a slot offered to everyone |
| `workflow-unreachable-state` | SHOULD | a state nothing can ever enter |
| `workflow-repeated-completion-condition` | CONSIDER | the same completion condition repeated at the end of several route arms |

**UI** · 37 rules

| Rule | Tier | Catches |
|---|---|---|
| `ui-app-has-no-home-page` | MUST | no page serving the app's front door |
| `ui-toggle-handler-writes-it-again` | MUST | a toggle whose handler writes the value the toggle already wrote |
| `ui-toggle-written-as-a-button` | MUST | a boolean written as a button instead of a toggle |
| `ui-action-never-invoked` | SHOULD | an action nothing in any render can invoke |
| `ui-atom-where-the-kit-has-a-control` | SHOULD | a raw atom hand-built where the kit ships the control |
| `ui-button-indistinguishable-from-a-text-field` | SHOULD | a button styled so it reads as a text field |
| `ui-component-reimplements-a-kit-control` | SHOULD | a component that hand-rolls what a bundled kit control already does |
| `ui-control-call-without-an-accessible-name` | SHOULD | a control call that passes no accessible name |
| `ui-currency-without-a-culture` | SHOULD | a currency formatted with no culture |
| `ui-date-kept-as-a-string` | SHOULD | a date kept as a string and bound to a plain text box |
| `ui-draft-field-ghosts-its-own-list` | SHOULD | a draft row created at mount on a component that lists the same entity, so it appears in its own list |
| `ui-enum-rendered-without-its-label` | SHOULD | an enum rendered by its member name instead of its label |
| `ui-guard-and-action-disagree-about-the-list` | SHOULD | a guard and its action reading two different lists |
| `ui-inert-affordance` | SHOULD | a control that accepts the click and does nothing |
| `ui-input-without-an-accessible-name` | SHOULD | an input with no accessible name |
| `ui-key-read-with-no-key-surface` | SHOULD | Keyboard.Down asked about a key no element declares, so it is false forever |
| `ui-label-drawn-twice` | SHOULD | a label drawn twice for one control |
| `ui-nondeterministic-render-slot` | SHOULD | a render-slot value whose behaviour depends on what else its expression reads |
| `ui-page-root-flush-against-the-viewport` | SHOULD | a page root that paints a surface and sits welded to the viewport edge |
| `ui-page-server-read-with-no-skeleton` | SHOULD | a page that reads from the server with nothing shown while it waits |
| `ui-row-guard-reads-the-unfiltered-list` | SHOULD | a per-row guard that reads the unfiltered list |
| `ui-spacing-step-looks-like-pixels` | SHOULD | a spacing argument that reads as pixels but is steps on the 0.25rem scale |
| `ui-state-nothing-reads` | SHOULD | a state field nothing reads |
| `ui-text-field-bound-to-a-number` | SHOULD | a text field bound to a number |
| `ui-theme-primary-collides-with-a-tone` | SHOULD | a theme's Primary is too close to a semantic tone the app also paints, so a destructive action looks ordinary |
| `ui-theme-token-shadows-nothing` | SHOULD | a theme token named to shadow a kit token that does not exist |
| `ui-control-state-not-announced` | CONSIDER | a control whose state a screen reader is never told |
| `ui-control-without-an-accessible-name` | CONSIDER | a control with no accessible name |
| `ui-data-read-declared-inside-render` | CONSIDER | a data read declared inside a render instead of as a field |
| `ui-date-rendered-without-a-format` | CONSIDER | a date rendered with no format |
| `ui-editable-field-no-error-surface` | CONSIDER | a field under a rule the browser cannot pre-empt, in a form that catches nothing |
| `ui-editable-field-write-policy-unreflected` | CONSIDER | an editable field whose write is gated by a declared policy the input does not reflect |
| `ui-mount-hook-is-a-fetch` | CONSIDER | an on-mount hook that only loads data a field could declare |
| `ui-page-no-title` | CONSIDER | a routed page with neither a Title nor a meta title |
| `ui-rank-rendered-as-a-position` | CONSIDER | a stored rank drawn as the reader's position in a loop over a filtered list, so it reads 1, 3 |
| `ui-raw-style-literal-repeated` | CONSIDER | the same raw style literal repeated where a token belongs |
| `ui-rendered-list-query-not-live` | CONSIDER | a rendered list bound to a query that is not live |

**Formatting** · 3 rules

| Rule | Tier | Catches |
|---|---|---|
| `format-throws-on-double` | MUST | an integer-only format on a Double, which throws at runtime |
| `format-double-custom-rounds-differently` | CONSIDER | a custom pattern on a Double that rounds differently on the client |
| `format-runs-on-the-server` | CONSIDER | a format the browser cannot reproduce, so it round-trips to the server |

**Client** · 1 rule

| Rule | Tier | Catches |
|---|---|---|
| `client-server-hop-in-class-method` | CONSIDER | a class method that quietly makes a server round trip |

**Testing** · 11 rules

| Rule | Tier | Catches |
|---|---|---|
| `testing-login-untested` | MUST | a login no test proves both ways |
| `testing-scope-names-text-not-a-container` | MUST | a test scope that names text rather than a container |
| `testing-app-has-no-tests` | SHOULD | an app that ships no tests at all |
| `testing-denial-provable-by-its-setup` | SHOULD | a denial the setup alone would prove, with nothing there to deny |
| `testing-gated-read-outside-runas` | SHOULD | a gated read asserted outside a runas, so a denial passes as empty |
| `testing-invariant-untested` | SHOULD | an invariant no Assert.Throws proves bites |
| `testing-page-never-driven` | SHOULD | a routed page no test ever visits |
| `testing-security-rule-untested` | SHOULD | a rule that can deny, and no test acting as a principal proves it does |
| `testing-test-without-assertion` | SHOULD | a test that asserts nothing |
| `testing-visible-on-a-number` | SHOULD | an Assert.Visible on a bare number, a contains-check the whole page can satisfy |
| `testing-write-denial-unproven` | SHOULD | a denied write with nothing anywhere proving the same write can succeed for anybody |


### What does it find in the sample apps?   {#lint-sample-run}

`osy lint` run over every sample app in the repository when this page was built — the same call a downloader
makes, on the same source. A rule set is only as credible as what it says about the apps its authors ship, so the
result is published whether or not it is clean.

**36 apps, 302 files: 1 MUST, 599 SHOULD and 117 CONSIDER findings.**

| App | Files | MUST | SHOULD | CONSIDER | Distinct rules |
|---|---|---|---|---|---|
| `agent-expenses` | 18 | 0 | 46 | 9 | 10 |
| `arcade` | 28 | 0 | 51 | 22 | 11 |
| `auth-demo` | 6 | 0 | 0 | 0 | 0 |
| `chart-demo` | 8 | 0 | 5 | 4 | 5 |
| `chat-demo` | 6 | 0 | 10 | 5 | 8 |
| `chat-room` | 6 | 0 | 16 | 2 | 9 |
| `concurrency` | 9 | 0 | 13 | 3 | 9 |
| `dialog-demo` | 6 | 0 | 17 | 1 | 8 |
| `docs-site` | 9 | 0 | 13 | 2 | 10 |
| `dropdown-demo` | 5 | 0 | 9 | 3 | 10 |
| `ember` | 15 | 0 | 38 | 8 | 13 |
| `entity-inheritance` | 9 | 0 | 35 | 0 | 9 |
| `file-manager` | 11 | 0 | 15 | 5 | 12 |
| `generic-grid` | 5 | 0 | 11 | 9 | 10 |
| `gestures` | 2 | 0 | 1 | 2 | 3 |
| `gridprobe` | 3 | 0 | 4 | 1 | 3 |
| `hello-osy` | 4 | 0 | 2 | 1 | 2 |
| `kanban` | 5 | 0 | 18 | 4 | 13 |
| `markdown-demo` | 6 | 0 | 0 | 0 | 0 |
| `media-demo` | 4 | 0 | 4 | 1 | 5 |
| `memory-lab` | 7 | 0 | 13 | 1 | 6 |
| `motion` | 3 | 0 | 1 | 0 | 1 |
| `shell-arrangements` | 7 | 1 | 11 | 1 | 4 |
| `shell-showcase` | 10 | 0 | 15 | 0 | 1 |
| `shop` | 6 | 0 | 12 | 1 | 5 |
| `tabbed_admin_paused` | 18 | 0 | 40 | 20 | 17 |
| `template-stretch` | 5 | 0 | 9 | 6 | 9 |
| `todo` | 3 | 0 | 0 | 0 | 0 |
| `wf-approvals` | 9 | 0 | 24 | 1 | 9 |
| `wf-expense-hitl` | 9 | 0 | 20 | 0 | 7 |
| `wf-fanout-quorum` | 11 | 0 | 27 | 0 | 7 |
| `wf-nightly-digest` | 7 | 0 | 12 | 0 | 5 |
| `wf-order-saga` | 9 | 0 | 31 | 0 | 7 |
| `wf-signup-invite` | 11 | 0 | 20 | 1 | 12 |
| `wf-supplier-dispatch` | 6 | 0 | 21 | 0 | 6 |
| `wf-support-sla` | 16 | 0 | 35 | 4 | 13 |


## Examples       {#examples}

```console
osy lint                  # what this app is missing
osy lint --strict         # fail the run on any MUST-tier finding
osy lint --json           # findings as JSON
osy lint --rules          # every rule: id, tier, what it catches
```

## See also       {#see-also}

[Understanding your app](https://osysharp.com/reference/local/understanding-your-app/) — the resolved model: what the app *is*.

[Explaining your app's security](https://osysharp.com/reference/local/explaining-your-app/) — who can do what, in plain English; `--with-findings` folds these findings into it.

[secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — the deny-all default the security rules are written against.

[security { }](https://osysharp.com/reference/security/entity-security/) — how to declare an entity's access rules.
