# The lint The iris over a site. A content push is an incoming wormhole; nothing comes through until it checks out. ``` portal iris --path questions # a local checkout portal lint --repo https://project.uhhm.no/uhhm/questions portal lint replay --path questions --nats nats://127.0.0.1:14222 ``` `portal iris` is the lint's name, after the iris that keeps bad content out of the portal; `portal lint` runs the same thing, so workflows that already call it keep working. It was its own binary, iris, until portal 0.6: the same code, released as portal's twin. Now it is the `portal` binary's `lint` command, so a content repo's CI downloads the release and runs it: ```yaml - run: | curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o portal chmod +x portal && ./portal lint --path questions ``` `check` loads a content repo exactly the way the running site does, validates it (every action lands on a page, every transition is one the state machine allows, every recorded bucket is read back somewhere), and then holds it to the business needs the repo declares in `needs.yaml`, when it has one: - a path of submissions gets each kind of visitor's answer into the bucket it names, within a submission and required-field budget; - the handling group holds a desk over that bucket, and every finished state is reachable through the buttons it (and any `also_moved_by` group) offers; no record strands on the way; - every stage the business named is a state of the machine, and each working stage can follow the one before it; - across each bucket, whichever need brought a record in: every state is somewhere a record can get to, no state a record gets to holds it with no way on (before done or after), every declared move is a button, a visitor's own link or a clock (a move nobody offers warns), no desk is gated to one group on the page and another on its list, and every mail goes out from a state records reach and names only fields the forms collect. Every finding says what to change and where: the missing `{ from, to, label }` and the desk page it goes on, the group that already has the button, or the edge aggregates.yaml lacks. A schema error no longer hides the needs findings - both are printed, so one round can fix them together. `--findings ` writes the same findings one JSON object per line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`, `bucket`, or `repo` for a schema error), `need` (the need's id, or the bucket's name), `subject` (the state, the move as `from>to`, or the page), `message` and `fix`. The file is written empty when the content is clean. `id` hashes scope, need, code and subject and nothing of the wording, so the same gap keeps its id from one run to the next: a trainer can tell what a round closed, what is still open and what it broke. The codes: | code | | |---|---| | `schema` | content the site would refuse to load | | `need-duplicate`, `persona-unknown-need` | needs.yaml itself | | `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on | | `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded | | `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket | | `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case | | `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages | | `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket | | `mail-unreachable`, `mail-unknown-field` | what a state mails | A need marked `planned` warns instead of failing. Exit status is the verdict, so a content repo's CI is one line. Beyond the verdict, `check` exports what a trainer needs to grade the wording: `--needs-tasks` writes the personas as typed choice tasks (`--skim` for headings only), `--needs-score` grades an engine's answers, and `--needs-sim` writes a resolved model of the whole site. `portal lint replay` runs simulated cases through the site's real aggregate engine on a throwaway JetStream and reports every bucket by state; `--report-only` takes the same scorecard from a live site's buckets. See [Training a site](training.md) for what sits around those. ## Matching the site The lint is the site: `portal lint` and `portal serve` are one binary built from one source, so a content shape the site reads is a shape the lint knows, at the same version. A content repo pins `PORTAL_RELEASE` to what its site runs and lints with that release's `portal`. The `IRIS_RELEASE` pins from before portal 0.6 keep working against the iris releases that exist; nothing new is published there.