2026-09-30 15:29:30 +02:00
|
|
|
# The lint
|
|
|
|
|
|
|
|
|
|
The iris over a site. A content push is an incoming wormhole; nothing
|
|
|
|
|
comes through until it checks out.
|
|
|
|
|
|
|
|
|
|
```
|
2026-10-01 21:41:56 +02:00
|
|
|
portal iris --path questions # a local checkout
|
2026-09-30 15:29:30 +02:00
|
|
|
portal lint --repo https://project.uhhm.no/uhhm/questions
|
|
|
|
|
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
|
|
|
|
```
|
|
|
|
|
|
2026-10-01 21:41:56 +02:00
|
|
|
`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.
|
|
|
|
|
|
2026-09-30 15:29:30 +02:00
|
|
|
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: |
|
2026-09-30 17:19:34 +02:00
|
|
|
curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o portal
|
2026-09-30 15:29:30 +02:00
|
|
|
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;
|
2026-10-02 11:25:49 +02:00
|
|
|
- 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.
|
2026-09-30 15:29:30 +02:00
|
|
|
|
2026-10-03 10:48:09 +02:00
|
|
|
`--findings <out.jsonl>` 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 |
|
|
|
|
|
|
2026-09-30 15:29:30 +02:00
|
|
|
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.
|