From 4049809aa4d652953eab8f41a8113a503c307171 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 1 Oct 2026 21:41:56 +0200 Subject: [PATCH] The lint refuses a form with no action, which would have no button; portal iris names the lint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 5 +++++ docs/lint.md | 6 +++++- src/content/validate.rs | 14 ++++++++++++++ src/main.rs | 7 ++++--- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfa717..a38f629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.16 (2026-10-01) + +- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone. +- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working. + ## 0.6.15 (2026-10-01) - An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good. diff --git a/docs/lint.md b/docs/lint.md index 5841556..faf47e7 100644 --- a/docs/lint.md +++ b/docs/lint.md @@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing comes through until it checks out. ``` -portal lint --path questions # a local checkout +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: diff --git a/src/content/validate.rs b/src/content/validate.rs index 3e1eb68..031cd80 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -512,6 +512,20 @@ pub fn validate_questions( } for question in questions.values() { for alternative in &question.alternatives { + // A form is sent with its button, and the button is only + // drawn for an alternative that says where to go afterwards: + // without `action` the person fills it in and cannot send it. + let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden")); + if (asks || alternative.invite.is_some() || alternative.record_as.is_some()) + && alternative.action.is_none() + && alternative.self_transition.is_none() + { + anyhow::bail!( + "question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)", + question.id, + alternative.name + ); + } if alternative.one_per_person && alternative.record_as.is_none() { anyhow::bail!( "question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in", diff --git a/src/main.rs b/src/main.rs index a10818b..d86e5f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ /// One binary, three jobs: `portal` (or `portal serve`) runs a site; -/// `portal lint` checks a content repo the way the site reads it; +/// `portal iris` checks a content repo the way the site reads it (the +/// iris keeps bad content out of the portal; `portal lint` is the same); /// `portal plan`, `build`, `report` and the rest train one. The lint /// and the trainer are the same code as the server, at the same /// version, so they never disagree about what content means. @@ -8,7 +9,7 @@ async fn main() -> anyhow::Result<()> { let argv: Vec = std::env::args().skip(1).collect(); match argv.first().map(String::as_str) { - Some("lint") => return portal::lint::run(argv[1..].to_vec()).await, + Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await, Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => { // The trainer runs models through shell commands and waits // on them; off the runtime's threads, as a CLI should. @@ -19,7 +20,7 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } Some("serve") | None => {} - Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"), + Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"), } serve().await } -- 2.54.0