specfold-core 0.7.1

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! T1: supersede claims pair up: the older document says what replaced it, and no chain loops.

use crate::check::context::{Ctx, key_line};
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::corpus::graph::query;

pub struct SupersedePairs;

impl Rule for SupersedePairs {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "T1",
            title: "superseded documents have exactly one successor",
            explain: "Supersession is written once, on the new document (rel.supersedes). Once the new document takes effect (active, or the first current status after its type's initial one; a type that starts active takes effect at once), the old document's status must be superseded, and only one document may claim it. A successor still in its initial status, such as a draft, leaves the old document as it is, and so does one that ended without taking effect (rejected). `spec new --from <id>` writes the relation; when the new document starts as a draft, `spec set <new> status=active --write` marks the old one superseded in the same write. `spec set <new> rel.supersedes+=<old> --write` records it by hand.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            // C204: a cross-type supersedes is an L3 error, not a successor.
            let successors: Vec<usize> = ctx
                .graph
                .edges_to(i)
                .filter(|e| e.kind == "supersedes" && ctx.corpus.docs[e.from].id.ty == d.id.ty)
                .map(|e| e.from)
                .collect();
            let covered = ctx.scope.covers(i) || successors.iter().any(|&s| ctx.scope.covers(s));
            if !covered {
                continue;
            }
            let status = d.status().unwrap_or_default();
            if let Some(cycle) = query::supersede_cycle(ctx.graph, i) {
                let ids: Vec<String> = cycle.iter().map(|&s| ctx.corpus.docs[s].id.to_string()).collect();
                out.push(
                    Finding::error(
                        "T1",
                        &d.path,
                        1,
                        format!("supersede cycle, nothing is current: {}", ids.join(" → ")),
                    )
                    .with_candidates(ids),
                );
            }
            if successors.len() > 1 {
                let c = successors.iter().map(|&s| ctx.corpus.docs[s].id.to_string()).collect();
                out.push(
                    Finding::error("T1", &d.path, 1, format!("{} documents supersede {}", successors.len(), d.id))
                        .with_candidates(c),
                );
            }
            // A successor that has not taken effect (a draft) leaves the old document in force.
            let holding: Vec<usize> = successors.iter().copied().filter(|&s| taken_effect(ctx, s)).collect();
            if !holding.is_empty() && status != "superseded" && status != "archived" {
                out.push(
                    Finding::error(
                        "T1",
                        &d.path,
                        key_line(ctx, &d.path, "status"),
                        format!("superseded by {} but status is '{status}'", ctx.corpus.docs[holding[0]].id),
                    )
                    .fix_cmd(superseded_fix(ctx, d, &status, holding[0])),
                );
            }
            if successors.is_empty() && status == "superseded" {
                out.push(Finding::error(
                    "T1",
                    &d.path,
                    key_line(ctx, &d.path, "status"),
                    "status is superseded but no document supersedes it",
                ));
            }
        }
    }
}

/// Whether document `i` holds or once held, so what it supersedes must say superseded.
fn taken_effect(ctx: &Ctx, i: usize) -> bool {
    let d = &ctx.corpus.docs[i];
    let status = d.status().unwrap_or_default();
    ctx.corpus.cfg.registry.get(&d.id.ty).is_none_or(|t| t.has_taken_effect(&status))
}

/// C204: the command that marks `d` superseded; through a middle status when the lifecycle
/// has no direct move (draft), else dropping the relation on the successor.
fn superseded_fix(ctx: &Ctx, d: &crate::model::Doc, status: &str, successor: usize) -> String {
    let me = crate::suggest::shell::word(&d.id);
    let Some(ty) = ctx.corpus.cfg.registry.get(&d.id.ty) else {
        return format!("spec set {me} status=superseded --write");
    };
    if ty.transition_allowed(status, "superseded") {
        return format!("spec set {me} status=superseded --write");
    }
    match ty.statuses.iter().find(|m| ty.transition_allowed(status, m) && ty.transition_allowed(m, "superseded")) {
        Some(m) => format!("spec set {me} status={m} --write && spec set {me} status=superseded --write"),
        None => {
            format!(
                "spec set {} rel.supersedes-={} --write",
                crate::suggest::shell::word(&ctx.corpus.docs[successor].id),
                d.id
            )
        }
    }
}