1 Commits
Author SHA1 Message Date
portal release edf401f773 Match portal v0.3.40
Publish release / publish (push) Successful in 23s
2026-09-23 11:51:22 +02:00
7 changed files with 13 additions and 683 deletions
+2 -42
View File
@@ -3,49 +3,9 @@
One line per change, grouped by the release that shipped it. Newest One line per change, grouped by the release that shipped it. Newest
first. Each release names the portal tag it is built against. first. Each release names the portal tag it is built against.
## 0.5.2 (2026-09-28) - portal v0.5.2 ## 0.3.40 (2026-09-23) - portal v0.3.40
- Matches portal v0.5.2 (released by portal's publish job). - Matches portal v0.3.40 (released by portal's publish job).
## 0.5.1 (2026-09-28) - portal v0.5.1
- Matches portal v0.5.1 (released by portal's publish job).
## 0.5.0 (2026-09-28) - portal v0.5.0
- Matches portal v0.5.0 (released by portal's publish job).
## 0.4.0 (2026-09-24) - portal v0.4.0
- Matches portal v0.4.0 (released by portal's publish job).
## 0.3.47 (2026-09-24) - portal v0.3.47
- Matches portal v0.3.47 (released by portal's publish job).
## 0.3.46 (2026-09-23) - portal v0.3.46
- Matches portal v0.3.46 (released by portal's publish job).
## 0.3.45 (2026-09-23) - portal v0.3.45
- Matches portal v0.3.45 (released by portal's publish job).
## 0.3.44 (2026-09-23) - portal v0.3.44
- Matches portal v0.3.44 (released by portal's publish job).
## 0.3.43 (2026-09-23) - portal v0.3.43
- Matches portal v0.3.43 (released by portal's publish job).
## 0.3.42 (2026-09-23) - portal v0.3.42
- Matches portal v0.3.42 (released by portal's publish job).
## 0.3.41 (2026-09-23) - portal v0.3.41
- Matches portal v0.3.41 (released by portal's publish job).
## 0.3.39 (2026-09-23) - portal v0.3.39 ## 0.3.39 (2026-09-23) - portal v0.3.39
Generated
+4 -224
View File
@@ -798,7 +798,7 @@ dependencies = [
"linked-hash-map", "linked-hash-map",
"linked_hash_set", "linked_hash_set",
"miette", "miette",
"nonempty 0.12.0", "nonempty",
"ref-cast", "ref-cast",
"regex", "regex",
"rustc-literal-escaper", "rustc-literal-escaper",
@@ -1060,12 +1060,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.5.5" version = "0.5.5"
@@ -1167,20 +1161,6 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.1" version = "2.11.1"
@@ -1543,16 +1523,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty 0.7.0",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1630,12 +1600,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.34"
@@ -1684,11 +1648,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1739,29 +1701,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -1823,23 +1762,6 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -2071,19 +1993,6 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.11.1",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -2287,7 +2196,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "iris" name = "iris"
version = "0.5.2" version = "0.3.40"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-nats", "async-nats",
@@ -3016,12 +2925,6 @@ dependencies = [
"signatory", "signatory",
] ]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]] [[package]]
name = "nonempty" name = "nonempty"
version = "0.12.0" version = "0.12.0"
@@ -3031,12 +2934,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3406,13 +3303,12 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.5.2" version = "0.3.40"
source = "git+https://project.uhhm.no/uhhm/portal.git?tag=v0.5.2#45240b7cabcc59f1802ad1bd432f512a47110d2c" source = "git+https://project.uhhm.no/uhhm/portal.git?tag=v0.3.40#44d5ef368371b7d0d004d9bb0c89a9db267fcfe6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
"async-nats", "async-nats",
"async-trait",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
@@ -3437,7 +3333,6 @@ dependencies = [
"tower", "tower",
"tower-http", "tower-http",
"tower-sessions", "tower-sessions",
"tower_governor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@@ -3587,21 +3482,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.12" version = "0.11.12"
@@ -3786,15 +3666,6 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.13.2",
]
[[package]] [[package]]
name = "reactive_graph" name = "reactive_graph"
version = "0.2.14" version = "0.2.14"
@@ -4695,15 +4566,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@@ -5003,17 +4865,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tokio-tungstenite" name = "tokio-tungstenite"
version = "0.29.0" version = "0.29.0"
@@ -5092,35 +4943,6 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2 0.4.19",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper 1.11.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -5129,12 +4951,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap 2.14.2",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -5246,23 +5065,6 @@ dependencies = [
"tower-sessions-core", "tower-sessions-core",
] ]
[[package]]
name = "tower_governor"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http 1.5.0",
"pin-project",
"thiserror 2.0.20",
"tonic",
"tower",
"tracing",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -5670,22 +5472,6 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -5695,12 +5481,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iris" name = "iris"
version = "0.5.2" version = "0.3.40"
edition = "2021" edition = "2021"
description = "The iris over a portal site: nothing from a content repo comes through until it checks out" description = "The iris over a portal site: nothing from a content repo comes through until it checks out"
license = "MIT" license = "MIT"
@@ -10,7 +10,7 @@ repository = "https://project.uhhm.no/uhhm/iris"
# The site's own content types and loaders, so iris reads a repo exactly # The site's own content types and loaders, so iris reads a repo exactly
# the way the running site does. Pinned to the portal release it # the way the running site does. Pinned to the portal release it
# matches; bumping this tag is what a new iris release is. # matches; bumping this tag is what a new iris release is.
portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.5.2", features = ["ssr"] } portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.3.40", features = ["ssr"] }
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+1 -36
View File
@@ -23,7 +23,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
let mut min_accuracy: f64 = 0.0; let mut min_accuracy: f64 = 0.0;
let mut skim = false; let mut skim = false;
let mut sim_out: Option<String> = None; let mut sim_out: Option<String> = None;
let mut show_access = false;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
@@ -34,7 +33,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
"--needs-tasks" => tasks_out = args.next(), "--needs-tasks" => tasks_out = args.next(),
"--skim" => skim = true, "--skim" => skim = true,
"--needs-sim" => sim_out = args.next(), "--needs-sim" => sim_out = args.next(),
"--access" => show_access = true,
"--needs-score" => answers_in = args.next(), "--needs-score" => answers_in = args.next(),
"--min-accuracy" => { "--min-accuracy" => {
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy) min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
@@ -64,7 +62,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
(questions, aggregates_map, site, needs_raw) (questions, aggregates_map, site, needs_raw)
} else { } else {
eprintln!( eprintln!(
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]" "usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
); );
std::process::exit(2); std::process::exit(2);
}; };
@@ -93,39 +91,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
questions.len(), questions.len(),
aggregates_map.len() aggregates_map.len()
); );
// The access policy the site will compile from this content.
// It must type-check against portal's schema: a rule the
// schema cannot express would grant nothing at runtime.
let policy = portal::access::Policy::from_content(&questions, &aggregates_map);
if let Err(e) = policy.validate() {
eprintln!("FAIL: access policy does not validate: {e}");
std::process::exit(1);
}
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
// What the site mails, checked before anyone receives it:
// placeholders that would go out blank, links to pages
// that are not there, grants and invites nobody hears of.
let findings = crate::mail::check(&questions, &aggregates_map, &site);
for f in &findings {
let tag = if f.level == needs::Level::Fail { "FAIL" } else { "WARN" };
eprintln!("{tag}: {}", f.text);
}
if findings.iter().any(|f| f.level == needs::Level::Fail) {
std::process::exit(1);
}
let mails = crate::mail::count(&aggregates_map, &site);
if mails > 0 {
println!("OK: {mails} mail(s): every placeholder fills, every link lands");
}
if show_access {
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
let mut rows = policy.rules.clone();
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
for r in rows {
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
}
println!();
}
check_needs( check_needs(
needs_raw.as_deref(), needs_raw.as_deref(),
&questions, &questions,
-327
View File
@@ -1,327 +0,0 @@
//! What the site's mail promises, checked before it is sent to anyone.
//!
//! A mail is read alone, away from the page that caused it, by a
//! person who cannot ask what a blank means. Portal renders a `{key}`
//! it has no value for as nothing, silently, and sends a link to a
//! page that is not there as readily as one that is. So the things a
//! mail can get wrong without any runtime error are checked here:
//!
//! - **fail** a placeholder no record in the bucket can fill: the mail
//! goes out with a hole in it;
//! - **fail** a link to a page the site does not have;
//! - **fail** a state that grants a group on a site that sends no mail:
//! the account is made and its sign-in link reaches nobody;
//! - **fail** an invite mail without its `{link}`, or with a
//! placeholder the invite cannot fill;
//! - **warn** a placeholder only some of the bucket's forms collect, or
//! one every form marks optional: it is blank in the mails about the
//! others, or about anyone who skipped it;
//! - **warn** a dialogue that does not close: the person is mailed on
//! the way in and not told how their case ended;
//! - **warn** a mail that tells a person their case is where they may
//! now act on it, and gives them no link to do so;
//! - **warn** a long answer (a `textarea` field) put in a subject line,
//! which becomes the whole brief in someone's inbox list.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use portal::aggregates::AggregateSchema;
use portal::content::{self, Question, SiteConfig};
use crate::needs::Level;
/// What portal fills in a case mail besides the record's own answers
/// (`mail.rs`, `vars_for`).
const CASE_VARS: &[&str] = &["email", "site", "chain", "bucket", "state"];
/// What portal fills in the invite mail (`mail.rs`, `on_invite`).
const INVITE_VARS: &[&str] = &["name", "username", "email", "group", "link", "site", "site_name", "expires"];
#[derive(Debug)]
pub struct Finding {
pub level: Level,
pub text: String,
}
/// `{key}` exactly as portal's renderer reads one: ASCII alphanumerics,
/// `_`, `-` and `.`, closed by `}`. A brace that opens no key is text.
pub fn placeholders(template: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut rest = template;
while let Some(start) = rest.find('{') {
let after = &rest[start + 1..];
let len = after
.char_indices()
.take_while(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if len > 0 && after[len..].starts_with('}') {
keys.push(after[..len].to_string());
rest = &after[len + 1..];
} else {
rest = after;
}
}
keys
}
/// The paths a template links to on its own site: `{site}/x/y?...`.
fn site_links(template: &str) -> Vec<String> {
template
.match_indices("{site}")
.map(|(i, _)| {
let tail = &template[i + "{site}".len()..];
let end = tail.find(|c: char| c.is_whitespace() || matches!(c, '?' | '#' | ')' | '>' | '"' | '\'')).unwrap_or(tail.len());
let path = tail[..end].trim_end_matches(['.', ',', ';', ':']);
if path.is_empty() { "/".to_string() } else { path.to_string() }
})
.collect()
}
fn page_exists(questions: &HashMap<String, Question>, path: &str) -> bool {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
questions.contains_key(path)
|| questions.values().any(|q| q.is_dynamic() && content::path_matches(&q.id, path).is_some())
}
/// For each bucket, the field names of every alternative that records
/// into it: one set per form.
fn forms_by_bucket(questions: &HashMap<String, Question>) -> BTreeMap<String, Vec<(String, BTreeSet<String>)>> {
let mut out: BTreeMap<String, Vec<(String, BTreeSet<String>)>> = BTreeMap::new();
for q in questions.values() {
for alt in &q.alternatives {
if let Some(bucket) = &alt.record_as {
let fields = alt.features.iter().flat_map(|f| &f.requirements).map(|r| r.name.clone()).collect();
out.entry(bucket.clone()).or_default().push((format!("{} / {}", q.id, alt.name), fields));
}
}
}
out
}
/// States with no move out of them: where a case ends.
fn endings(schema: &AggregateSchema) -> BTreeSet<String> {
let mut states: BTreeSet<String> = schema.event_for_state.keys().cloned().collect();
states.insert(schema.initial.clone());
states.into_iter().filter(|s| schema.allowed(s).is_empty()).collect()
}
/// States reachable from `from` along declared moves, `from` included.
fn reachable(schema: &AggregateSchema, from: &str) -> BTreeSet<String> {
let mut seen = BTreeSet::from([from.to_string()]);
let mut todo = vec![from.to_string()];
while let Some(s) = todo.pop() {
for next in schema.allowed(&s) {
if seen.insert(next.clone()) {
todo.push(next.clone());
}
}
}
seen
}
pub fn check(questions: &HashMap<String, Question>, aggregates: &HashMap<String, AggregateSchema>, site: &SiteConfig) -> Vec<Finding> {
let mut findings = Vec::new();
let mut fail = |text: String| findings.push(Finding { level: Level::Fail, text });
let forms = forms_by_bucket(questions);
let mut warns = Vec::new();
// Where the submitter may act on their own record: bucket -> state
// -> the labels of the moves they may make from there.
let mut own_moves: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
for q in questions.values() {
for alt in &q.alternatives {
if let Some(st) = &alt.self_transition {
if let Some(schema) = aggregates.get(&st.bucket) {
for from in st.from_states(&schema.initial) {
own_moves.entry(st.bucket.clone()).or_default().entry(from).or_default().push(st.label.clone());
}
}
}
}
}
let mut buckets: Vec<&AggregateSchema> = aggregates.values().collect();
buckets.sort_by(|a, b| a.bucket.cmp(&b.bucket));
for schema in buckets {
let bucket = &schema.bucket;
let bucket_forms = forms.get(bucket).cloned().unwrap_or_default();
let mut states: Vec<(&String, &portal::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
states.sort_by(|a, b| a.0.cmp(b.0));
for (state, mail) in &states {
let at = format!("mail on {bucket}:{state}");
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
if CASE_VARS.contains(&key.as_str()) {
continue;
}
let having: Vec<&String> = bucket_forms.iter().filter(|(_, f)| f.contains(&key)).map(|(n, _)| n).collect();
if having.is_empty() {
fail(format!("{at}: {{{key}}} is not a field any form recording into {bucket:?} collects, nor one portal fills ({}) - it would be sent blank", CASE_VARS.join(", ")));
} else if questions
.values()
.flat_map(|q| &q.alternatives)
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
.flat_map(|a| a.features.iter().flat_map(|f| &f.requirements))
.filter(|r| r.name == key)
.all(|r| r.optional)
{
warns.push(format!("{at}: {{{key}}} is optional on every form - blank for anyone who skipped it"));
} else if having.len() < bucket_forms.len() {
let missing: Vec<&String> = bucket_forms.iter().filter(|(_, f)| !f.contains(&key)).map(|(n, _)| n).collect();
warns.push(format!("{at}: {{{key}}} is blank for records sent from {}", missing.iter().map(|n| format!("{n:?}")).collect::<Vec<_>>().join(", ")));
}
}
for key in placeholders(&mail.subject) {
let long = questions.values().flat_map(|q| &q.alternatives).filter(|a| a.record_as.as_deref() == Some(bucket.as_str())).flat_map(|a| a.features.iter().flat_map(|f| &f.requirements)).any(|r| r.name == key && r.kind == "textarea");
if long {
warns.push(format!("{at}: the subject carries {{{key}}}, a long answer (textarea) - the whole of it lands in the subject line"));
}
}
for path in site_links(&mail.body).into_iter().chain(site_links(&mail.subject)) {
if path.contains('{') {
continue; // filled per record; the page is checked where the pattern is declared
}
if !page_exists(questions, &path) {
fail(format!("{at}: links to {path:?}, which is not a page on this site"));
}
}
}
if !schema.grants_for_state.is_empty() && site.mail.is_none() {
let mut granting: Vec<&String> = schema.grants_for_state.keys().collect();
granting.sort();
fail(format!("{bucket}: state(s) {granting:?} grant a group, and site.yaml has no `mail: {{ from }}` - the account would be made and its sign-in link sent to nobody"));
}
// The dialogue closes: a person mailed on the way in hears how
// it ended, from every ending their case can still reach.
let to_submitter: BTreeSet<&String> = states.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| *s).collect();
if let Some(first) = to_submitter.iter().min_by_key(|s| (s.as_str() != schema.initial, s.to_string())) {
let reach = reachable(schema, first);
let silent: Vec<String> = endings(schema).into_iter().filter(|e| reach.contains(e) && !to_submitter.contains(e)).collect();
if !silent.is_empty() {
warns.push(format!("{bucket}: the person is mailed at {first:?} and not told when their case ends at {silent:?}"));
}
}
// A mail telling a person their case is where they may act on
// it carries the link that lets them.
for (state, mail) in &states {
if !mail.to_submitter() {
continue;
}
if let Some(labels) = own_moves.get(bucket).and_then(|m| m.get(*state)) {
if !mail.body.contains("{chain}") {
warns.push(format!("mail on {bucket}:{state}: the person may now {:?} themselves, and the mail gives them no link (`{{chain}}`) to do it", labels));
}
}
}
}
if let Some(invite) = site.mail.as_ref().and_then(|m| m.invite.as_ref()) {
let keys: Vec<String> = placeholders(&invite.subject).into_iter().chain(placeholders(&invite.body)).collect();
for key in &keys {
if !INVITE_VARS.contains(&key.as_str()) {
fail(format!("site.yaml mail.invite: {{{key}}} is not something the invite mail knows ({}) - it would be sent blank", INVITE_VARS.join(", ")));
}
}
if !invite.body.contains("{link}") {
fail("site.yaml mail.invite: the body has no {link} - the person invited could never sign in".to_string());
}
}
findings.extend(warns.into_iter().map(|text| Finding { level: Level::Warn, text }));
findings
}
/// How many mails the site declares, invite included.
pub fn count(aggregates: &HashMap<String, AggregateSchema>, site: &SiteConfig) -> usize {
aggregates.values().map(|s| s.mail_for_state.len()).sum::<usize>()
+ usize::from(site.mail.as_ref().is_some_and(|m| m.invite.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholders_are_read_the_way_portal_renders_them() {
assert_eq!(placeholders("Hi {name}, see {site}/decide/x?chain={chain}. {not a key} {}"), ["name", "site", "chain"]);
assert_eq!(site_links("Open {site}/decide/trial?chain={chain}. Or {site}."), ["/decide/trial", "/"]);
}
fn site(yaml: &str) -> SiteConfig {
serde_yaml::from_str(yaml).unwrap()
}
fn repo(aggregates: &str, pages: &[(&str, &str)]) -> (HashMap<String, Question>, HashMap<String, AggregateSchema>) {
let aggregates = portal::aggregates::parse_aggregates_yaml(aggregates).unwrap();
let questions = pages
.iter()
.map(|(id, yaml)| {
let mut q: Question = serde_yaml::from_str(yaml).unwrap();
q.id = id.to_string();
(id.to_string(), q)
})
.collect();
(questions, aggregates)
}
const AGG: &str = "aggregates:\n - bucket: trials\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: \"Got it, {name}\", body: \"Withdraw at {site}/decide/trial?chain={chain}\" } }\n approved: { event: approved, mail: { to: submitter, subject: Yes, body: \"{business} is approved\" } }\n declined: { event: declined }\n withdrawn: { event: withdrawn }\n transitions:\n open: [approved, declined, withdrawn]\n";
const FORM: &str = "name: Q?\nalternatives:\n - name: Try it\n record_as: trials\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n";
const DECIDE: &str = "name: Decide?\nalternatives:\n - name: Withdraw\n self_transition: { bucket: trials, to: withdrawn, label: Withdraw }\n";
#[test]
fn a_blank_placeholder_a_dead_link_and_a_silent_ending_are_found() {
let (q, a) = repo(AGG, &[("/", FORM), ("/decide/trial", DECIDE)]);
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
let text = |lvl: Level| f.iter().filter(|x| x.level == lvl).map(|x| x.text.clone()).collect::<Vec<_>>();
let fails = text(Level::Fail);
assert_eq!(fails.len(), 1, "{fails:?}");
assert!(fails[0].contains("{business}"));
let warns = text(Level::Warn);
assert!(warns.iter().any(|w| w.contains("declined") && w.contains("withdrawn")), "{warns:?}");
let (q, a) = repo(AGG, &[("/", FORM)]);
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
assert!(f.iter().any(|x| x.level == Level::Fail && x.text.contains("/decide/trial")));
}
#[test]
fn a_grant_needs_a_sender_and_an_invite_needs_its_link() {
let agg = "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received }\n in: { event: in, grants: members }\n transitions:\n open: [in]\n";
let (q, a) = repo(agg, &[("/", "name: Q?\nalternatives:\n - name: A\n record_as: b\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n")]);
assert!(check(&q, &a, &site("title: T\n")).iter().any(|x| x.level == Level::Fail && x.text.contains("sent to nobody")));
let f = check(&q, &a, &site("mail: { from: x@y.no, invite: { subject: \"Hi {name}\", body: \"Welcome to {site_name}, {nickname}\" } }\n"));
assert!(f.iter().any(|x| x.text.contains("{nickname}")));
assert!(f.iter().any(|x| x.text.contains("no {link}")));
}
#[test]
fn an_optional_answer_in_a_mail_is_found() {
let agg = AGG.replace("Got it, {name}", "Got it, {nick}");
let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: nick, optional: true }\n");
let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]);
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{nick}") && x.text.contains("optional")), "{f:?}");
}
#[test]
fn a_long_answer_in_a_subject_line_is_found() {
let agg = AGG.replace("Got it, {name}", "About {brief}");
let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: brief, type: textarea }\n");
let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]);
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{brief}") && x.text.contains("subject")), "{f:?}");
}
#[test]
fn a_mail_about_a_case_the_person_may_act_on_links_to_it() {
let agg = AGG.replace("Withdraw at {site}/decide/trial?chain={chain}", "We have it");
let (q, a) = repo(&agg, &[("/", FORM), ("/decide/trial", DECIDE)]);
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("no link")), "{f:?}");
}
}
+1 -2
View File
@@ -18,7 +18,6 @@
//! it matches, so the lint and the site never disagree about what a //! it matches, so the lint and the site never disagree about what a
//! page is. //! page is.
mod mail;
mod check; mod check;
mod local; mod local;
mod needs; mod needs;
@@ -45,7 +44,7 @@ async fn main() -> anyhow::Result<()> {
Some(flag) if flag.starts_with("--") => check::run(argv).await, Some(flag) if flag.starts_with("--") => check::run(argv).await,
_ => { _ => {
eprintln!( eprintln!(
"iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)", "iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)",
v = env!("CARGO_PKG_VERSION") v = env!("CARGO_PKG_VERSION")
); );
std::process::exit(2) std::process::exit(2)
+3 -50
View File
@@ -73,11 +73,6 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI. /// as a warning, so the gap stays visible without failing CI.
#[serde(default)] #[serde(default)]
pub planned: bool, pub planned: bool,
/// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model.
#[serde(default)]
pub stage_words: std::collections::BTreeMap<String, String>,
/// The finished states that are a good outcome for the business - /// The finished states that are a good outcome for the business -
/// a subset of `done_when`. "lost" is finished; "won" is success. /// a subset of `done_when`. "lost" is finished; "won" is success.
/// What a simulation or a live bucket report counts toward. /// What a simulation or a live bucket report counts toward.
@@ -576,8 +571,7 @@ pub fn sim_model(
json!({ json!({
"page": step.page, "page": step.page,
"question": page.name, "question": page.name,
"context": page_context(page), "options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({
"options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({
"name": a.name, "name": a.name,
"description": plain(&a.description), "description": plain(&a.description),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
@@ -603,7 +597,6 @@ pub fn sim_model(
json!({ json!({
"initial": schema.initial, "initial": schema.initial,
"transitions": schema.transitions, "transitions": schema.transitions,
"words": need.stage_words,
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({ "desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
"page": d.page, "page": d.page,
"group": d.group, "group": d.group,
@@ -653,41 +646,6 @@ fn plain(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ") text.split_whitespace().collect::<Vec<_>>().join(" ")
} }
/// A card a visitor can act on: it leads to a page, records an
/// answer, or moves a record. A card with none of those is read, not
/// picked - the opener that says what the business does, the look
/// around - and is context on the page, never an option.
pub fn leads_somewhere(a: &Alternative) -> bool {
a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some()
}
/// What the page says before its options: every card that leads
/// nowhere, its heading, description and features, as one line each.
pub fn page_context(page: &Question) -> String {
page.alternatives
.iter()
.filter(|a| !a.disabled && !leads_somewhere(a))
.map(|a| {
let mut line = a.name.clone();
let description = plain(&a.description);
if !description.is_empty() {
line.push_str(&format!(": {description}"));
}
for f in &a.features {
let text = plain(&f.description);
match (f.name.trim().is_empty(), text.is_empty()) {
(true, true) => {}
(true, false) => line.push_str(&format!(" {text}")),
(false, true) => line.push_str(&format!(" {}.", f.name.trim())),
(false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())),
}
}
line
})
.collect::<Vec<_>>()
.join("\n")
}
/// Every (persona, page-on-their-path) pair with a real choice on it. /// Every (persona, page-on-their-path) pair with a real choice on it.
/// `skim` shows the engine only each alternative's heading - what a /// `skim` shows the engine only each alternative's heading - what a
/// visitor who never reads the description has to go on. /// visitor who never reads the description has to go on.
@@ -702,11 +660,10 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
}; };
for (i, step) in path.iter().enumerate() { for (i, step) in path.iter().enumerate() {
let page = &questions[&step.page]; let page = &questions[&step.page];
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect();
if open.len() < 2 { if open.len() < 2 {
continue; continue;
} }
let context = page_context(page);
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect(); let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter() .into_iter()
@@ -727,11 +684,7 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
persona: persona.id.clone(), persona: persona.id.clone(),
need: need.id.clone(), need: need.id.clone(),
page: step.page.clone(), page: step.page.clone(),
state: if context.is_empty() { state: BTreeMap::from([("body", plain(&persona.text))]),
BTreeMap::from([("body", plain(&persona.text))])
} else {
BTreeMap::from([("body", plain(&persona.text)), ("page", context)])
},
questions: BTreeMap::from([( questions: BTreeMap::from([(
"pick", "pick",
ChoiceQuestion { ChoiceQuestion {