Skip to main content

speccoder_runtime/
ladder.rs

1//! Where work came from, written once: `<project state>/ladder/origins.json`,
2//! one record per subject (a unit, or `<unit>/<requirement>`). Only
3//! [`record_origin`] writes it; a second write for a subject is refused.
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use speccoder_core::ladder::Origin;
9
10/// The store's file.
11#[must_use]
12pub fn file(project_state: &Path) -> PathBuf {
13    project_state.join("ladder").join("origins.json")
14}
15
16/// Every recorded subject's origins.
17#[must_use]
18pub fn origins(project_state: &Path) -> BTreeMap<String, Vec<Origin>> {
19    std::fs::read_to_string(file(project_state))
20        .ok()
21        .and_then(|t| serde_json::from_str(&t).ok())
22        .unwrap_or_default()
23}
24
25/// The origins of `subject`.
26#[must_use]
27pub fn of(project_state: &Path, subject: &str) -> Vec<Origin> {
28    origins(project_state).remove(subject).unwrap_or_default()
29}
30
31/// Records `subject`'s origins, once.
32///
33/// # Errors
34///
35/// It was recorded before, or the store cannot be written.
36pub fn record_origin(
37    project_state: &Path,
38    subject: &str,
39    origin: Vec<Origin>,
40) -> Result<(), String> {
41    let mut all = origins(project_state);
42    if all.contains_key(subject) {
43        return Err(format!(
44            "the origin of {subject} is recorded already; it is written once"
45        ));
46    }
47    all.insert(subject.to_owned(), origin);
48    let f = file(project_state);
49    if let Some(parent) = f.parent() {
50        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
51    }
52    let text = serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?;
53    let tmp = f.with_extension("json.tmp");
54    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
55    std::fs::rename(&tmp, &f).map_err(|e| format!("{}: {e}", f.display()))
56}
57
58/// A quick change or bug that grew: the signals that hold for one task.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Offer {
61    /// The lower unit.
62    pub unit: String,
63    /// Its task.
64    pub task: String,
65    /// The requirement the task cites, with its words.
66    pub requirement: Option<(String, String)>,
67    /// What holds, measured.
68    pub signals: Vec<speccoder_core::ladder::Signal>,
69    /// A bug's unit, not a quick change's.
70    pub bug: bool,
71}
72
73/// `[ladder] promote_files`: more changed files than this is a signal.
74const PROMOTE_FILES: u64 = 8;
75
76fn promote_files(root: &Path) -> u64 {
77    crate::verify::find_config(root)
78        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
79        .and_then(|t| t.parse::<toml::Table>().ok())
80        .and_then(|t| t.get("ladder")?.get("promote_files")?.as_integer())
81        .and_then(|n| u64::try_from(n).ok())
82        .unwrap_or(PROMOTE_FILES)
83}
84
85/// What decides verdicts: a change to it is a signal of its own.
86fn judges(path: &str) -> bool {
87    path == crate::verify::config::FILE || path.starts_with(".github/workflows/")
88}
89
90/// The manifest roots of `files` (a file outside any is the root's).
91fn packages(root: &Path, files: &[String]) -> u64 {
92    const MANIFESTS: [&str; 4] = ["Cargo.toml", "package.json", "pyproject.toml", "go.mod"];
93    let mut found: Vec<PathBuf> = files
94        .iter()
95        .map(|f| {
96            let mut dir = Path::new(f).parent().map(Path::to_path_buf);
97            while let Some(d) = dir {
98                if MANIFESTS.iter().any(|m| root.join(&d).join(m).is_file()) {
99                    return d;
100                }
101                dir = d.parent().map(Path::to_path_buf);
102            }
103            PathBuf::new()
104        })
105        .collect();
106    found.sort();
107    found.dedup();
108    found.len() as u64
109}
110
111fn changed(root: &Path, commit: &str) -> Vec<String> {
112    crate::git::Git::at(root)
113        .run(&["diff", "--name-only", &format!("{commit}^"), commit])
114        .map(|b| {
115            String::from_utf8_lossy(&b)
116                .lines()
117                .map(str::to_owned)
118                .filter(|l| !l.is_empty())
119                .collect()
120        })
121        .unwrap_or_default()
122}
123
124/// Declined offers: `<unit>/<task>` → the signals that held then.
125fn declined_file(project_state: &Path) -> PathBuf {
126    project_state.join("ladder").join("declined.json")
127}
128
129fn declined(project_state: &Path) -> BTreeMap<String, Vec<String>> {
130    std::fs::read_to_string(declined_file(project_state))
131        .ok()
132        .and_then(|t| serde_json::from_str(&t).ok())
133        .unwrap_or_default()
134}
135
136/// Records that the person declined `offer`: it comes back only when a
137/// signal that did not hold then holds.
138///
139/// # Errors
140///
141/// The store cannot be written.
142pub fn decline(project_state: &Path, offer: &Offer) -> Result<(), String> {
143    let mut all = declined(project_state);
144    all.insert(
145        format!("{}/{}", offer.unit, offer.task),
146        offer.signals.iter().map(|s| s.id.clone()).collect(),
147    );
148    let f = declined_file(project_state);
149    if let Some(parent) = f.parent() {
150        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
151    }
152    let text = serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?;
153    std::fs::write(&f, text).map_err(|e| format!("{}: {e}", f.display()))
154}
155
156/// The promotion offers of `unit`, a quick-change or bug unit: one per
157/// task whose recorded change holds a signal, none declined on the same
158/// signals, none promoted already.
159#[must_use]
160pub fn offers(root: &Path, project_state: &Path, unit: &str) -> Vec<Offer> {
161    let format = crate::format::at(root);
162    let rung = crate::oracle::rung(format, root, unit);
163    if rung == speccoder_core::ladder::Rung::Feature {
164        return Vec::new();
165    }
166    let report = crate::status::report(root, Some(unit));
167    let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
168        return Vec::new();
169    };
170    let review = crate::review::report(root, Some(unit));
171    let requirements: Vec<(String, String)> = match review.data() {
172        Some(speccoder_core::CommandData::Review(v)) => v
173            .requirements
174            .iter()
175            .map(|q| (q.id.clone(), q.text.clone()))
176            .collect(),
177        _ => Vec::new(),
178    };
179    let saved = crate::attempts::list(root, unit);
180    let promoted = origins(project_state);
181    let declined = declined(project_state);
182    let limit = promote_files(root);
183    let mut out = Vec::new();
184    for t in &r.tasks {
185        let attempts: Vec<&crate::attempts::Saved> =
186            saved.iter().filter(|a| a.task == t.id).collect();
187        let Some(commit) = t
188            .commit
189            .clone()
190            .or_else(|| attempts.last().map(|a| a.commit.clone()))
191        else {
192            continue;
193        };
194        if promoted.values().flatten().any(|o| {
195            matches!(o, Origin::PromotedFrom { from_unit, task, .. } if from_unit == unit && *task == t.id)
196        }) {
197            continue;
198        }
199        // The unit's own planning files (its tick among them) are not the change.
200        let files: Vec<String> = changed(root, &commit)
201            .into_iter()
202            .filter(|f| !Path::new(f).components().any(|c| c.as_os_str() == unit))
203            .collect();
204        let requirement = requirements
205            .iter()
206            .find(|(id, _)| {
207                t.title
208                    .split_whitespace()
209                    .any(|w| w.trim_matches(|c: char| !c.is_alphanumeric() && c != '-') == id)
210            })
211            .cloned();
212        let ambiguity = requirement.as_ref().map_or(0, |(_, text)| {
213            let flagged = crate::shape::findings("requirement", text)
214                .iter()
215                .filter(|f| matches!(f.rule.as_str(), "vague" | "escape_clause" | "open_ended"))
216                .count();
217            (flagged + text.matches("[NEEDS CLARIFICATION").count()) as u64
218        });
219        let input = speccoder_core::ladder::SignalInput {
220            files_changed: files.len() as u64,
221            packages_changed: packages(root, &files),
222            red_causes: attempts
223                .iter()
224                .filter(|a| {
225                    !matches!(
226                        a.outcome.as_str(),
227                        "blocked" | "interrupted" | "awaiting_approval"
228                    )
229                })
230                .map(|a| {
231                    a.reason
232                        .lines()
233                        .next()
234                        .unwrap_or_default()
235                        .trim()
236                        .to_owned()
237                })
238                .collect(),
239            ambiguity_markers: ambiguity,
240            judge_paths: files.iter().filter(|f| judges(f)).cloned().collect(),
241            promote_files: limit,
242        };
243        let signals = speccoder_core::ladder::signals(&input);
244        let before = declined.get(&format!("{unit}/{}", t.id));
245        if signals.is_empty() || before.is_some_and(|b| signals.iter().all(|s| b.contains(&s.id))) {
246            continue;
247        }
248        out.push(Offer {
249            unit: unit.to_owned(),
250            task: t.id.clone(),
251            requirement,
252            signals,
253            bug: rung == speccoder_core::ladder::Rung::Bug,
254        });
255    }
256    out
257}
258
259/// The conversations an offer carries by default: the ones that suggested
260/// its requirement.
261#[must_use]
262pub fn suggested_by(project_state: &Path, offer: &Offer) -> Vec<String> {
263    let requirement = offer.requirement.as_ref().map_or("", |(id, _)| id.as_str());
264    of(
265        project_state,
266        &speccoder_core::ladder::subject(&offer.unit, requirement),
267    )
268    .into_iter()
269    .filter_map(|x| match x {
270        Origin::Asked { conversation, .. } => Some(conversation),
271        _ => None,
272    })
273    .collect()
274}
275
276/// Promotion as an action: plan through creates a unit from the offer's
277/// requirement word for word, carrying `conversations`.
278#[must_use]
279pub fn promotion(offer: &Offer, conversations: Vec<String>) -> speccoder_core::actions::Runnable {
280    use speccoder_core::actions::{FeatureSource, PlanFrom, Runnable, Through};
281    Runnable::PlanThrough {
282        from: PlanFrom::Create {
283            description: offer
284                .requirement
285                .clone()
286                .map(|(_, w)| w)
287                .unwrap_or_default(),
288            source: FeatureSource::Promotion {
289                task: format!("{}/{}", offer.unit, offer.task),
290                conversations,
291            },
292        },
293        through: Through::Tasks,
294    }
295}
296
297/// `speccoder spec promote <unit>/<task>`: the offer for it, if one holds.
298#[must_use]
299pub fn offer_for(root: &Path, project_state: &Path, task: &str) -> Option<Offer> {
300    let (unit, id) = task.rsplit_once('/')?;
301    offers(root, project_state, unit)
302        .into_iter()
303        .find(|o| o.task == id)
304}
305
306/// The unit `task` (`<unit>/<task>`) was promoted to, if it was.
307#[must_use]
308pub fn promoted_to(project_state: &Path, task: &str) -> Option<String> {
309    let (unit, id) = task.rsplit_once('/')?;
310    origins(project_state).into_iter().find_map(|(to, o)| {
311        o.iter()
312            .any(|o| matches!(o, Origin::PromotedFrom { from_unit, task, .. } if from_unit == unit && task == id))
313            .then_some(to)
314    })
315}
316
317/// `speccoder spec promote --decline`: declines the offer for `task`
318/// (`<unit>/<task>`).
319#[must_use]
320pub fn decline_task(root: &Path, state: &Path, task: &str) -> speccoder_core::CommandResult {
321    use speccoder_core::{CommandResult, CommandStatus};
322    let plane = crate::worktree::project_state_dir(state, root);
323    let Some((unit, id)) = task.rsplit_once('/') else {
324        return CommandResult::new(
325            "promote",
326            CommandStatus::UsageError,
327            "name the task as <unit>/<task>",
328        );
329    };
330    let Some(offer) = offers(root, &plane, unit)
331        .into_iter()
332        .find(|o| o.task == id)
333    else {
334        return CommandResult::new(
335            "promote",
336            CommandStatus::NotFound,
337            &format!("no promotion is offered for {task}"),
338        );
339    };
340    match decline(&plane, &offer) {
341        Ok(()) => CommandResult::ok(
342            "promote",
343            &format!(
344                "not now: {task} stays a {}; offered again only when another signal holds",
345                if offer.bug { "bug fix" } else { "quick change" }
346            ),
347        ),
348        Err(e) => CommandResult::new("promote", CommandStatus::Error, &e),
349    }
350}
351
352/// What the creating skill reads for a promotion: the description the
353/// person confirmed, then each carried conversation word for word.
354#[must_use]
355pub fn promotion_input(
356    root: &Path,
357    state: &Path,
358    description: &str,
359    conversations: &[String],
360) -> String {
361    let mut out = description.trim().to_owned();
362    for id in conversations {
363        if let Some((words, _)) = crate::ask::words(root, state, id) {
364            out.push_str(&format!(
365                "\n\n<context kind=\"conversation\" id=\"{id}\">\nAn Ask conversation that led to this change; the person chose to carry it.\n\n{words}</context>"
366            ));
367        }
368    }
369    out
370}
371
372/// Once plan through made the new unit: its origin, written once, and
373/// `unit.promoted` said to `progress`.
374pub fn promoted(
375    root: &Path,
376    state: &Path,
377    made: &speccoder_core::CommandResult,
378    task: &str,
379    conversations: &[String],
380    via: speccoder_core::actions::Via,
381    progress: Option<&std::sync::Arc<dyn crate::progress::Progress>>,
382) {
383    let Some(speccoder_core::CommandData::PlanningChain(r)) = made.data() else {
384        return;
385    };
386    let (Some(unit), Some((from_unit, id))) = (r.unit.as_deref(), task.rsplit_once('/')) else {
387        return;
388    };
389    let plane = crate::worktree::project_state_dir(state, root);
390    let offer = offers(root, &plane, from_unit)
391        .into_iter()
392        .find(|o| o.task == id);
393    let (requirement, words) = offer
394        .as_ref()
395        .and_then(|o| o.requirement.clone())
396        .unwrap_or_default();
397    let carried: Vec<(String, String)> = conversations
398        .iter()
399        .filter_map(|c| crate::ask::words(root, state, c).map(|(_, d)| (c.clone(), d)))
400        .collect();
401    let signals: Vec<String> = offer
402        .map(|o| o.signals.into_iter().map(|s| s.id).collect())
403        .unwrap_or_default();
404    let requirement_sha256 = crate::integrity::sha256_hex(words.as_bytes());
405    let by = crate::approvals::who_in(root);
406    let written = record_origin(
407        &plane,
408        unit,
409        vec![Origin::PromotedFrom {
410            from_unit: from_unit.to_owned(),
411            requirement: requirement.clone(),
412            task: id.to_owned(),
413            requirement_sha256: requirement_sha256.clone(),
414            conversations: carried.clone(),
415            signals: signals.clone(),
416            by: by.clone(),
417            via,
418            at: crate::bug::confirm::now(),
419        }],
420    );
421    if let (Ok(()), Some(p)) = (written, progress) {
422        p.event(&crate::progress::Event::UnitPromoted {
423            unit: unit.to_owned(),
424            from_unit: from_unit.to_owned(),
425            requirement,
426            task: id.to_owned(),
427            requirement_sha256,
428            conversation_sha256: carried.into_iter().map(|(_, d)| d).collect(),
429            signals,
430            by,
431            via: via.as_str().to_owned(),
432        });
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn an_origin_is_written_once() {
442        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
443        let o = vec![Origin::Issue {
444            number: 7,
445            repo: None,
446            context_sha256: None,
447        }];
448        assert!(record_origin(state.path(), "007-bugfixes/FR-001", o.clone()).is_ok());
449        assert!(record_origin(state.path(), "007-bugfixes/FR-001", o.clone()).is_err());
450        assert!(record_origin(state.path(), "007-bugfixes/FR-002", o.clone()).is_ok());
451        assert_eq!(of(state.path(), "007-bugfixes/FR-001"), o);
452    }
453}