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:
co-authored by
Claude Opus 5.5
parent
2cc74e3354
commit
92dd9bfc79
+15
-1
@@ -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
@@ -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
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user