The lint walks every state of every bucket a need lands in

Clocks count as moves and a visitor's link only from its own states.
Per bucket: a state nothing reaches fails, a state a record can reach
and never leave fails (after done too), a declared move nobody offers
warns, a desk gated twice warns, and a mail from an unreachable state
or naming a field no form collects is caught. Working stages must
follow each other. Every finding ends in its fix, and a schema error no
longer hides the needs findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-02 11:25:49 +02:00
co-authored by Claude Opus 5.5
parent 2cc74e3354
commit 92dd9bfc79
4 changed files with 487 additions and 41 deletions
+15 -1
View File
@@ -34,7 +34,21 @@ in `needs.yaml`, when it has one:
- the handling group holds a desk over that bucket, and every - the handling group holds a desk over that bucket, and every
finished state is reachable through the buttons it (and any finished state is reachable through the buttons it (and any
`also_moved_by` group) offers; no record strands on the way; `also_moved_by` group) offers; no record strands on the way;
- every stage the business named is a state of the machine. - 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.
A need marked `planned` warns instead of failing. Exit status is the A need marked `planned` warns instead of failing. Exit status is the
verdict, so a content repo's CI is one line. verdict, so a content repo's CI is one line.
+23 -12
View File
@@ -126,11 +126,33 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
} }
Err(e) => { Err(e) => {
eprintln!("FAIL: {e}"); eprintln!("FAIL: {e}");
// One schema error is no reason to hide the rest: the needs
// pass reads the same content and says what else is wrong,
// so a fix round can take them all at once.
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
print_findings(&needs::check(&file, &questions, &aggregates_map));
}
std::process::exit(1); std::process::exit(1);
} }
} }
} }
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
fn print_findings(findings: &[needs::Finding]) -> usize {
let mut failed = 0;
for finding in findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message);
}
failed
}
/// The business-needs pass (see `crate::lint::needs`): structure always, /// The business-needs pass (see `crate::lint::needs`): structure always,
/// then the wording tasks written out and/or an engine's answers /// then the wording tasks written out and/or an engine's answers
/// scored, when asked. A repo without `needs.yaml` skips all of it. /// scored, when asked. A repo without `needs.yaml` skips all of it.
@@ -158,18 +180,7 @@ fn check_needs(
std::process::exit(1); std::process::exit(1);
} }
}; };
let findings = needs::check(&file, questions, aggregates_map); let failed = print_findings(&needs::check(&file, questions, aggregates_map));
let mut failed = 0;
for finding in &findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
}
for need in &file.needs { for need in &file.needs {
if let Some(path) = needs::path_for(questions, need) { if let Some(path) = needs::path_for(questions, need) {
let steps: Vec<String> = path let steps: Vec<String> = path
+439 -27
View File
@@ -137,9 +137,13 @@ pub enum Level {
#[derive(Debug)] #[derive(Debug)]
pub struct Finding { pub struct Finding {
/// The need's id, or the bucket's name when `scope` is "bucket".
pub need: String, pub need: String,
pub level: Level, pub level: Level,
pub message: String, pub message: String,
/// "need" for a need's own path; "bucket" for what holds for every
/// record in a bucket whichever need brought it in.
pub scope: &'static str,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -255,6 +259,10 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
struct Desk { struct Desk {
page: String, page: String,
group: Option<String>, group: Option<String>,
/// The page's own `qualifies`, kept apart from the resource's
/// `requires_group`: when both are set and differ, only members of
/// both ever see the buttons.
page_group: Option<String>,
/// (from, to, button label) /// (from, to, button label)
moves: Vec<(String, String, String)>, moves: Vec<(String, String, String)>,
} }
@@ -272,6 +280,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
desks.push(Desk { desks.push(Desk {
page: q.id.clone(), page: q.id.clone(),
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()), group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
page_group: q.qualifies.clone(),
moves: resource moves: resource
.transitions .transitions
.iter() .iter()
@@ -284,15 +293,49 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
desks desks
} }
/// Self-service moves into `bucket` - a visitor holding their own /// Self-service moves in `bucket` - a visitor holding their own
/// record's link, no group involved (unsubscribe). /// record's link, no group involved (unsubscribe, withdraw) - as
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> { /// (from, to), from the states each may be made from.
fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> {
questions questions
.values() .values()
.flat_map(|q| &q.alternatives) .flat_map(|q| &q.alternatives)
.filter_map(|a| a.self_transition.as_ref()) .filter_map(|a| a.self_transition.as_ref())
.filter(|s| s.bucket == bucket) .filter(|s| s.bucket == bucket)
.map(|s| s.to.clone()) .flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone())))
.collect()
}
/// The moves a clock makes in `schema` (`after:` on a state), as
/// (from, to). Nobody presses them, and they still move records.
fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> {
let mut moves: Vec<(String, String)> =
schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect();
moves.sort();
moves
}
/// States reachable from `start` along `moves`.
fn reachable(start: &str, moves: &[(String, String)]) -> HashSet<String> {
let mut reached = HashSet::from([start.to_string()]);
let mut queue = VecDeque::from([start.to_string()]);
while let Some(state) = queue.pop_front() {
for (_, to) in moves.iter().filter(|(from, _)| *from == state) {
if reached.insert(to.clone()) {
queue.push_back(to.clone());
}
}
}
reached
}
/// Who else offers `from -> to` in the bucket, as "page (group)", for
/// a finding that has to say where the missing button already is.
fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec<String> {
desks
.iter()
.filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to))
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
.collect() .collect()
} }
@@ -305,10 +348,16 @@ pub fn check(
) -> Vec<Finding> { ) -> Vec<Finding> {
let mut findings = Vec::new(); let mut findings = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
// (bucket, state) a need already reported - stranding on its way
// to done, or out of its reach - so the bucket pass does not say
// it twice.
let mut strands_reported: HashSet<(String, String)> = HashSet::new();
let mut unreached_reported: HashSet<(String, String)> = HashSet::new();
for need in &file.needs { for need in &file.needs {
let mut report = |level: Level, message: String| { let mut report = |level: Level, message: String| {
findings.push(Finding { findings.push(Finding {
need: need.id.clone(), need: need.id.clone(),
scope: "need",
level: if need.planned || need.paused { Level::Warn } else { level }, level: if need.planned || need.paused { Level::Warn } else { level },
message: match (need.paused, need.planned, level == Level::Fail) { message: match (need.paused, need.planned, level == Level::Fail) {
(true, _, true) => format!("paused, not open now: {message}"), (true, _, true) => format!("paused, not open now: {message}"),
@@ -439,23 +488,44 @@ pub fn check(
); );
continue; continue;
} }
let selfs = self_moves(questions, &need.lands_in); // Every move that carries this need's records: the buttons on
// the desks of handled_by/also_moved_by, the visitor's own
// links, and the clocks.
let mut moves: Vec<(String, String)> = own
.iter()
.flat_map(|d| &d.moves)
.filter(|(from, to, _)| schema.allowed(from).contains(to))
.map(|(from, to, _)| (from.clone(), to.clone()))
.collect();
moves.extend(self_moves(questions, &need.lands_in, &schema.initial));
moves.extend(clock_moves(schema));
let exits = |state: &str| -> Vec<String> { let exits = |state: &str| -> Vec<String> {
let allowed = schema.allowed(state); moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect()
let mut to: Vec<String> = own };
.iter() let movers = std::iter::once(need.handled_by.as_str())
.flat_map(|d| &d.moves) .chain(need.also_moved_by.iter().map(String::as_str))
.filter(|(from, to, _)| from == state && allowed.contains(to)) .map(|g| format!("{g:?}"))
.map(|(_, to, _)| to.clone()) .collect::<Vec<_>>()
.collect(); .join(" or ");
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned()); // The fix for a missing move: the button on handled_by's desk,
to // or the group whose desk already has it added to also_moved_by.
let fix_for = |from: &str, to: &str| -> String {
let elsewhere = offered_elsewhere(&desks, from, to);
let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk");
if elsewhere.is_empty() {
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}")
} else {
format!(
"{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}",
elsewhere.join(", ")
)
}
}; };
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect(); let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
// Reachability walks the whole machine (a finished state may // Reachability walks the whole machine (a finished state may
// lead on to another: subscribed, then unsubscribed). Stranding // lead on to another: subscribed, then unsubscribed). Stranding
// only counts on the way to the first finished state - what // only counts on the way to the first finished state here; what
// happens to a handled record afterwards is not this need. // strands a record afterwards is the bucket's finding (below).
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]); let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
let mut queue = VecDeque::from([(schema.initial.clone(), false)]); let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
let mut stranded = Vec::new(); let mut stranded = Vec::new();
@@ -471,6 +541,26 @@ pub fn check(
} }
} }
} }
// The declared edges into `state` from somewhere a record does
// get to: the moves that would close the gap.
let ways_in = |state: &str| -> Vec<(String, String)> {
let mut ways: Vec<(String, String)> = reached
.iter()
.filter(|from| schema.allowed(from).iter().any(|to| to == state))
.map(|from| (from.clone(), state.to_string()))
.collect();
ways.sort();
ways
};
let missing = |state: &str| -> String {
match ways_in(state).first() {
Some((from, to)) => format!(" - fix: {}", fix_for(from, to)),
None => format!(
" - and no state a record reaches has a declared edge into it: add one in aggregates.yaml under {:?}",
need.lands_in
),
}
};
for state in &need.done_when { for state in &need.done_when {
if !schema.has_state(state) { if !schema.has_state(state) {
report( report(
@@ -478,11 +568,13 @@ pub fn check(
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in), format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
); );
} else if !reached.contains(state) { } else if !reached.contains(state) {
unreached_reported.insert((need.lands_in.clone(), state.clone()));
report( report(
Level::Fail, Level::Fail,
format!( format!(
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves", "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}",
need.handled_by need.handled_by,
missing(state)
), ),
); );
} }
@@ -497,11 +589,36 @@ pub fn check(
), ),
); );
} else if !reached.contains(stage) { } else if !reached.contains(stage) {
unreached_reported.insert((need.lands_in.clone(), stage.clone()));
report( report(
Level::Fail, Level::Fail,
format!( format!(
"stage {stage:?} exists but {:?} is offered no moves that reach it", "stage {stage:?} exists but {:?} is offered no moves that reach it{}",
need.handled_by need.handled_by,
missing(stage)
),
);
}
}
// In the business's order: each working stage reachable from
// the one before it, so a machine where "quoted" cannot follow
// "surveyed" does not pass for one that goes surveyed -> quoted.
// The finished states that close the list are alternatives
// (won, lost), not a sequence, so they are left out.
for pair in need.stages.windows(2) {
let (before, after) = (&pair[0], &pair[1]);
if done.contains(before.as_str()) || done.contains(after.as_str()) {
continue;
}
if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) {
report(
Level::Fail,
format!(
"stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}",
match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) {
Some(to) => format!(" - fix: {}", fix_for(before, to)),
None => format!(" - add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in),
}
), ),
); );
} }
@@ -515,22 +632,32 @@ pub fn check(
} }
} }
stranded.sort(); stranded.sort();
for state in &stranded {
strands_reported.insert((need.lands_in.clone(), state.clone()));
}
for state in stranded { for state in stranded {
let fix = match schema.allowed(&state).first() {
Some(to) => format!(" - fix: {}", fix_for(&state, to)),
None => format!(
" - and {:?} declares no way out of it: add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml",
need.lands_in
),
};
report( report(
Level::Fail, Level::Fail,
format!( format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"),
"records strand in {state:?}: {:?} is offered no move out of it",
need.handled_by
),
); );
} }
} }
findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported));
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect(); let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
for persona in &file.personas { for persona in &file.personas {
if !need_ids.contains(persona.need.as_str()) { if !need_ids.contains(persona.need.as_str()) {
findings.push(Finding { findings.push(Finding {
need: persona.need.clone(), need: persona.need.clone(),
scope: "need",
level: Level::Fail, level: Level::Fail,
message: format!("persona {:?} points at a need that is not declared", persona.id), message: format!("persona {:?} points at a need that is not declared", persona.id),
}); });
@@ -539,6 +666,188 @@ pub fn check(
findings findings
} }
/// What must hold for every record in a bucket some need lands in,
/// whichever need brought it: 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 someone has, every
/// desk can be seen by whoever its buttons are for, and every mail
/// goes out from a state records reach, saying only what the record
/// holds.
fn operations(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
strands_reported: &HashSet<(String, String)>,
unreached_reported: &HashSet<(String, String)>,
) -> Vec<Finding> {
let mut findings = Vec::new();
let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect();
buckets.sort();
buckets.dedup();
for bucket in buckets {
let Some(schema) = aggregates.get(bucket) else { continue };
let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect();
// Only open needs make a gap fail; a bucket that only planned
// or paused needs use reports it as a warning.
let open = needs.iter().any(|n| !n.planned && !n.paused);
let mut report = |level: Level, message: String| {
findings.push(Finding {
need: bucket.to_string(),
scope: "bucket",
level: if open { level } else { Level::Warn },
message,
})
};
let desks = desks_over(questions, bucket);
let mut moves: Vec<(String, String)> = desks
.iter()
.flat_map(|d| &d.moves)
.filter(|(from, to, _)| schema.allowed(from).contains(to))
.map(|(from, to, _)| (from.clone(), to.clone()))
.collect();
moves.extend(self_moves(questions, bucket, &schema.initial));
moves.extend(clock_moves(schema));
let reached = reachable(&schema.initial, &moves);
let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to);
// Where a missing button goes: the desk of the group answerable
// for the bucket's first need.
let home = needs
.iter()
.find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str())))
.map(|d| d.page.clone())
.unwrap_or_else(|| "the handling group's desk".to_string());
let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}");
let mut states: Vec<&String> = schema.event_for_state.keys().collect();
states.sort();
for state in &states {
if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) {
continue;
}
let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect();
ways.sort();
report(
Level::Fail,
format!(
"state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}",
match ways.first() {
Some(from) => button(from, state),
None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(),
}
),
);
}
for state in states.iter().filter(|s| reached.contains(**s)) {
let out = schema.allowed(state);
if out.is_empty() {
continue; // a final state: nothing should move it
}
let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect();
if offered_out.is_empty() {
if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) {
report(
Level::Fail,
format!(
"records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}",
out.join(", "),
button(state, &out[0])
),
);
}
continue;
}
for to in out.iter().filter(|to| !offered(state, to)) {
report(
Level::Warn,
format!(
"the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - {}, or drop the edge from aggregates.yaml",
button(state, to)
),
);
}
}
for desk in &desks {
if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) {
if page_group != group {
report(
Level::Warn,
format!(
"the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?} - only members of both see the buttons; make them the same group",
desk.page
),
);
}
}
}
// The fields a record of this bucket holds: everything asked on
// a form that records into it.
let fields: Vec<(&str, &str)> = questions
.values()
.flat_map(|q| &q.alternatives)
.filter(|a| a.record_as.as_deref() == Some(bucket))
.flat_map(|a| &a.features)
.flat_map(|f| &f.requirements)
.map(|r| (r.name.as_str(), r.kind.as_str()))
.collect();
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
mail_states.sort_by(|a, b| a.0.cmp(b.0));
for (state, mail) in mail_states {
if !reached.contains(state) {
report(Level::Warn, format!("the mail on entering {state:?} never goes out: no record reaches {state:?}"));
}
let mut unknown: Vec<String> = Vec::new();
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
let known = match key.split_once('.') {
Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"),
None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key),
};
if !known && !unknown.contains(&key) {
unknown.push(key);
}
}
if !unknown.is_empty() {
let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
names.sort();
names.dedup();
report(
Level::Fail,
format!(
"the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects - it would go out blank there; the forms collect: {}",
unknown.iter().map(|k| format!("{{{k}}}")).collect::<Vec<_>>().join(", "),
if names.is_empty() { "nothing".to_string() } else { names.join(", ") }
),
);
}
}
}
findings
}
/// What the mailer fills in on every state mail, whatever the record
/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`).
const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"];
/// The `{key}`s in a mail template, read the way `mail::render` reads
/// them: letters, digits, `_`, `-` and `.`, closed by `}`.
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() || *c == '_' || *c == '-' || *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;
}
keys
}
/// Everything a simulation of the business needs, resolved: each /// Everything a simulation of the business needs, resolved: each
/// need's path with the choice on every page and the form that gets /// need's path with the choice on every page and the form that gets
/// filled, each bucket's state machine with the buttons every desk /// filled, each bucket's state machine with the buttons every desk
@@ -611,6 +920,11 @@ pub fn sim_model(
"steps": steps, "steps": steps,
})); }));
if let Some(schema) = aggregates.get(&need.lands_in) { if let Some(schema) = aggregates.get(&need.lands_in) {
// The simulator's shape: the states a visitor's own link
// moves to (it does not model where from).
let mut self_move_targets: Vec<String> =
self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect();
self_move_targets.dedup();
buckets.entry(need.lands_in.clone()).or_insert_with(|| { buckets.entry(need.lands_in.clone()).or_insert_with(|| {
json!({ json!({
"initial": schema.initial, "initial": schema.initial,
@@ -623,7 +937,7 @@ pub fn sim_model(
"from": from, "to": to, "label": label, "from": from, "to": to, "label": label,
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
"self_moves": self_moves(questions, &need.lands_in), "self_moves": self_move_targets,
}) })
}); });
} }
@@ -895,8 +1209,8 @@ mod tests {
assert_eq!( assert_eq!(
fails(&findings), fails(&findings),
vec![ vec![
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves", "\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
"records strand in \"quoted\": \"owners\" is offered no move out of it", "records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
] ]
); );
} }
@@ -1037,4 +1351,102 @@ mod tests {
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]); assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
} }
/// The fixture site with its aggregates replaced, for the checks
/// that are about the state machine rather than the pages.
fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap<String, AggregateSchema>) {
let (questions, _) = site(desk_moves);
let aggregates = parse_aggregates_yaml(&format!(
"aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n"
))
.unwrap();
(questions, aggregates)
}
fn warns(findings: &[Finding]) -> Vec<&str> {
findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect()
}
#[test]
fn a_clock_is_a_move() {
// Nobody closes a stale quote; six weeks do. That is not a strand.
let (questions, aggregates) = site_with(
" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n",
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n",
);
let findings = check(&needs(""), &questions, &aggregates);
assert!(fails(&findings).is_empty(), "{findings:?}");
}
#[test]
fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() {
// "archived" follows "won" but no desk offers it; "parked" has
// no way in at all.
let (questions, aggregates) = site_with(
FULL_DESK,
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n",
);
let findings = check(&needs(""), &questions, &aggregates);
let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect();
assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}");
assert_eq!(
fails(&findings),
vec![
"state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
"state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
"state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
"records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
]
);
}
#[test]
fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() {
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n");
let findings = check(&needs(""), &questions, &aggregates);
assert!(fails(&findings).is_empty(), "{findings:?}");
assert_eq!(
warns(&findings),
vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"]
);
}
#[test]
fn working_stages_must_follow_each_other() {
// The business surveys, then quotes; this machine can only
// quote straight from open, and surveying leads nowhere.
let (questions, aggregates) = site_with(
" - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n",
" initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n",
);
let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""]
);
}
#[test]
fn a_mail_may_only_say_what_the_record_holds() {
let (questions, aggregates) = site_with(
FULL_DESK,
" initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n",
);
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects - it would go out blank there; the forms collect: brief, budget, email"]
);
}
#[test]
fn a_desk_gated_twice_is_seen_only_by_members_of_both() {
let (mut questions, aggregates) = site(FULL_DESK);
questions.get_mut("/desk").unwrap().qualifies = Some("staff".into());
let findings = check(&needs(""), &questions, &aggregates);
assert!(
warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\" - only members of both see the buttons; make them the same group"),
"{findings:?}"
);
}
} }
+10 -1
View File
@@ -190,7 +190,16 @@ not done. Its messages are exact:
- `no desk gated on "g" reads "b"`: add the desk. - `no desk gated on "g" reads "b"`: add the desk.
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk - `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
is missing a transition the state machine allows, or the state is missing a transition the state machine allows, or the state
machine is missing the edge. machine is missing the edge. The message ends in `fix:` with the
exact `{ from, to, label }` and the desk page to put it on - do that.
- `state "s" is never reached`: nothing moves a record into it. Offer
the move it names, or remove the state.
- `stage "b" comes after "a" ... but no move leads on`: the machine
cannot go the way the business works; add the edge it names.
- `the mail on entering "s" uses {x}`: the mail names a field no form
collects. Use one of the fields it lists, or collect `x`.
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
offer it, or drop the edge - every edge should be a button.
- `action ... does not match any declared question id`: the page does - `action ... does not match any declared question id`: the page does
not exist. not exist.
- `entry page ... names no one responsible`: add `responsible`. - `entry page ... names no one responsible`: add `responsible`.