//! L rules: every reference resolves to one document, written as an id.
use super::support::{distance, key_line, quote, skipped};
use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::{Rule, RuleInfo};
use crate::graph::{DOC_RELATIONS, Resolution};
use crate::model::RefText;
pub struct LinksResolve;
pub struct NoPathCites;
pub struct RelationsResolve;
pub struct AnchorsUnique;
pub struct GovernsExists;
fn ids(ctx: &Ctx, list: &[usize]) -> Vec<String> {
list.iter().map(|&i| ctx.corpus.docs[i].id.to_string()).collect()
}
/// The external root (an agent's memory, personal notes) holding a note named `target`.
fn external_note(ctx: &Ctx, target: &str) -> Option<String> {
let cfg = &ctx.corpus.cfg;
cfg.raw
.external
.iter()
.find(|(_, ext)| cfg.expand_path(&ext.path).join(format!("{target}.md")).is_file())
.map(|(name, _)| name.clone())
}
impl Rule for LinksResolve {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "L1",
title: "every [[reference]] resolves to one document",
explain: "Body references are written [[<id>]] or [[<id>#<anchor>]]. A reference that names nothing, an anchor that does not exist, or an alias shared by two documents is an error. A Markdown link whose target is a document id, `[text](<id>)`, is reported with the [[<id>|text]] to write instead (`spec fix --rule L1 --write` rewrites it); the built-in export rewrites the ones that resolve. An alias that resolves is accepted but reported, with the id to write instead; `spec fix --rule L1 --write` rewrites them all. [[<remote>:<id>]] names a document in a repository listed in [remotes]; it is checked when that directory is on disk and skipped when it is not.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers_links(i) || skipped(d, "L1") {
continue;
}
for l in &d.links {
match ctx.graph.resolve(&l.item) {
Resolution::Doc { idx, anchor, via_alias: true } => {
let mut id = ctx.corpus.docs[idx].id.to_string();
if let Some(a) = anchor {
id = format!("{id}#{a}");
}
let how = if ctx.graph.by_slug.contains_key(RefText::parse(&l.item).target.as_str()) {
"names by its slug"
} else {
"is an alias of"
};
out.push(
Finding::warn("L1", &l.path, l.line, format!("[[{}]] {how} {id}", l.item))
.fix_replace(format!("[[{}]]", l.item), format!("[[{id}]]")),
);
}
Resolution::Doc { .. } => {}
Resolution::Ambiguous(list) => {
let c = ids(ctx, &list);
out.push(
Finding::error(
"L1",
&l.path,
l.line,
format!("[[{}]] is ambiguous ({} documents)", l.item, c.len()),
)
.fix_cmd(format!(
"spec fix --rule L1 --pick {} --write {}:{}",
crate::shell::word(&c[0]),
quote(&l.path),
l.line
))
.with_candidates(c),
);
}
Resolution::MissingAnchor { idx, anchor } => {
let have: Vec<String> = ctx.graph.anchors[idx].iter().cloned().collect();
out.push(
Finding::error(
"L1",
&l.path,
l.line,
format!("{} has no anchor #{anchor}", ctx.corpus.docs[idx].id),
)
.with_candidates(have),
);
}
Resolution::Missing => {
if let Some(msg) = remote_problem(ctx, &l.item, None) {
if let Some(m) = msg {
out.push(Finding::error("L1", &l.path, l.line, format!("[[{}]] {m}", l.item)));
}
continue;
}
let target = RefText::parse(&l.item).target;
if let Some(root) = external_note(ctx, &target) {
out.push(Finding::warn(
"L1",
&l.path,
l.line,
format!(
"[[{}]] is a note in the external root {root}, which other readers cannot open",
l.item
),
));
continue;
}
out.push(
Finding::error("L1", &l.path, l.line, format!("[[{}]] names no document", l.item))
.fix_cmd(format!("spec find {}", quote(&target))),
);
}
}
}
md_id_links(ctx, d, out);
}
}
}
/// `[text](plan:root/…)` is a Markdown link, not a reference: nothing checks it and a reader's
/// tool cannot open it. Warns and offers `[[id|text]]`.
fn md_id_links(ctx: &Ctx, d: &crate::model::Doc, out: &mut Vec<Finding>) {
let files = std::iter::once(d.path.as_str()).chain(d.chapters.iter().map(String::as_str));
for path in files {
let Some(text) = ctx.current(path) else { continue };
for o in crate::export::links::md_id_occurrences(&text) {
let ty = o.target.split(':').next().unwrap_or_default();
if !ctx.corpus.docs.iter().any(|x| x.id.ty == ty) {
continue;
}
let line = text[..o.span.0].matches('\n').count() + 1;
let resolved = ctx.graph.resolve(&o.target);
// A slug id or alias is written as the canonical id, so the fix needs no second pass.
let target = match &resolved {
Resolution::Doc { idx, anchor, .. } => {
let id = ctx.corpus.docs[*idx].id.to_string();
anchor.as_ref().map_or(id.clone(), |a| format!("{id}#{a}"))
}
_ => o.target.clone(),
};
let wiki = match &o.label {
Some(l) => format!("[[{target}|{l}]]"),
None => format!("[[{target}]]"),
};
let f = match resolved {
Resolution::Doc { .. } => Finding::warn(
"L1",
path,
line,
format!("{} is a Markdown link to a document; write {wiki} so check and export follow it", o.raw),
)
.fix_replace(o.raw.clone(), wiki),
_ => Finding::warn(
"L1",
path,
line,
format!(
"{} is a Markdown link to a document id that names no document; write {wiki} with the id from spec find",
o.raw
),
),
};
out.push(f);
}
}
}
impl Rule for NoPathCites {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "L2",
title: "documents are cited by id, not by path",
explain: "A path breaks when a document moves; an id does not. Plain-text paths into a spec directory are reported with the id to write instead; a stale path (after a move or an import) is matched by file name or slug, and `spec fix --rule L2 --write` rewrites every cite with one match. `spec mv` rewrites plain paths to the moved file itself.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers(i) || skipped(d, "L2") {
continue;
}
for c in &d.path_cites {
let cites =
|tail: &str| Finding::error("L2", &c.path, c.line, format!("cites the path {}{tail}", c.item));
out.push(match ctx.graph.resolve(&c.item) {
Resolution::Doc { idx, .. } => {
cites("").fix_replace(c.item.clone(), format!("[[{}]]", ctx.corpus.docs[idx].id))
}
_ => {
let f = cites(", which is not a document (no such file)");
match by_file_name(ctx, &c.item).as_slice() {
[idx] => f.fix_replace(c.item.clone(), format!("[[{}]]", ctx.corpus.docs[*idx].id)),
[] => match by_near_path(ctx, &c.item).as_slice() {
[] => f,
near => f.with_candidates(ids(ctx, near)),
},
many => f.with_candidates(ids(ctx, many)),
}
}
});
}
}
}
}
/// Documents a stale path still names by its file: the same file name, or the same slug once a
/// stamp prefix and a `.<type>` suffix are stripped. Moves and imports keep slugs, so this finds
/// most documents whose old paths were cited.
fn by_file_name(ctx: &Ctx, cited: &str) -> Vec<usize> {
let file = cited.rsplit('/').next().unwrap_or(cited);
let docs = &ctx.corpus.docs;
let same: Vec<usize> = (0..docs.len()).filter(|&i| docs[i].path.rsplit('/').next() == Some(file)).collect();
if !same.is_empty() {
return same;
}
let mut stem = file.strip_suffix(".md").unwrap_or(file);
if let Some((s, ty)) = stem.rsplit_once('.')
&& ctx.corpus.cfg.registry.get(ty).is_some()
{
stem = s;
}
if let Some((stamp, rest)) = stem.split_once('-')
&& crate::model::stamp::is_stamp(stamp)
{
stem = rest;
}
if stem.is_empty() {
return vec![];
}
(0..docs.len()).filter(|&i| docs[i].slug == stem).collect()
}
/// Documents whose path is a near miss of a cited path (a typo): the closest within a few edits,
/// all of them when tied.
fn by_near_path(ctx: &Ctx, cited: &str) -> Vec<usize> {
let cited = cited.trim_start_matches("./");
let docs = &ctx.corpus.docs;
let scored: Vec<(usize, usize)> = (0..docs.len())
.map(|i| (distance(cited, &docs[i].path), i))
.filter(|(d, i)| *d <= 3.max(docs[*i].path.rsplit('/').next().unwrap_or("").len() / 6))
.collect();
let Some(best) = scored.iter().map(|(d, _)| *d).min() else { return vec![] };
scored.into_iter().filter(|(d, _)| *d == best).map(|(_, i)| i).collect()
}
/// A `<remote>:<id>` reference: `None` when it is not one, `Some(None)` when it resolves (or
/// the remote's directory is absent, which is not reported), `Some(Some(problem))` otherwise.
/// With a relation, the target's type is checked as for local targets.
fn remote_problem(ctx: &Ctx, text: &str, rel: Option<(&str, &str)>) -> Option<Option<String>> {
use crate::remotes::Remote;
Some(match crate::remotes::resolve(&ctx.corpus.cfg, text) {
Remote::Local => return None,
Remote::Absent { .. } => None,
Remote::Found { ty, .. } => match rel.map(|(kind, own)| (allowed_targets(kind), kind, own)) {
Some((Some([]), kind, own)) if ty != own => Some(format!("is a {ty}; rel.{kind} needs a {own}")),
Some((Some(list), kind, _)) if !list.is_empty() && !list.contains(&ty.as_str()) => {
Some(format!("is a {ty}, which rel.{kind} cannot point at"))
}
_ => None,
},
Remote::Missing { name, id } => Some(format!("names no document in the remote {name} ({id})")),
Remote::MissingAnchor { name, id, anchor } => Some(format!("{name}:{id} has no anchor #{anchor}")),
Remote::Ambiguous { name, ids } => Some(format!("is ambiguous in the remote {name}: {}", ids.join(", "))),
})
}
/// The types a relation may point at; `None` means any type, `Some(&[])` means the same type.
fn allowed_targets(kind: &str) -> Option<&'static [&'static str]> {
match kind {
"supersedes" => Some(&[]),
"implements" => Some(&["sot", "adr", "page", "design"]),
"evidence" => Some(&["evidence", "research", "audit"]),
_ => None,
}
}
impl Rule for RelationsResolve {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "L3",
title: "relations resolve and point at the right types",
explain: "rel.supersedes names documents of the same type, rel.implements names rules or decisions (sot, adr, page, design), rel.evidence names evidence, research or audits, rel.depends_on, rel.translation_of and rel.exception_of name anything, and rel.governs names code paths. A target written <remote>:<id> is looked up in that repository from [remotes], with the same type rules, and skipped when its directory is absent. Other relation names are reported.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers_links(i) {
continue;
}
for (kind, targets) in d.rel() {
if kind == "governs" {
continue;
}
let line = key_line(ctx, &d.path, "rel");
if !DOC_RELATIONS.contains(&kind.as_str()) {
out.push(Finding::warn("L3", &d.path, line, format!("unknown relation rel.{kind}")));
continue;
}
for t in targets {
match ctx.graph.resolve(&t) {
Resolution::Doc { idx, .. } => {
let ty = &ctx.corpus.docs[idx].id.ty;
let ok = match allowed_targets(&kind) {
Some([]) => ty == &d.id.ty,
Some(list) => list.contains(&ty.as_str()),
None => true,
};
if !ok {
out.push(Finding::error(
"L3",
&d.path,
line,
format!("rel.{kind} cannot point at a {ty} ({t})"),
));
}
}
Resolution::Ambiguous(list) => out.push(
Finding::error("L3", &d.path, line, format!("rel.{kind} target {t} is ambiguous"))
.with_candidates(ids(ctx, &list)),
),
_ => match remote_problem(ctx, &t, Some((&kind, &d.id.ty))) {
Some(None) => {}
Some(Some(m)) => {
out.push(Finding::error("L3", &d.path, line, format!("rel.{kind} target {t} {m}")))
}
None => out.push(Finding::error(
"L3",
&d.path,
line,
format!("rel.{kind} target {t} names no document"),
)),
},
}
}
}
}
}
}
impl Rule for AnchorsUnique {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "L4",
title: "anchors are unique within a document",
explain: "Headings carry stable anchors as `## Title {#ANCHOR}`; references point at them with [[<id>#ANCHOR]]. One anchor per name per document, across its chapters.",
on_commit: true,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers(i) {
continue;
}
let mut seen = std::collections::HashSet::new();
for a in &d.anchors {
if !seen.insert(&a.item) {
out.push(Finding::error("L4", &a.path, a.line, format!("anchor #{} is defined twice", a.item)));
}
}
}
}
}
impl Rule for GovernsExists {
fn info(&self) -> &RuleInfo {
&RuleInfo {
id: "L5",
title: "governed paths exist",
explain: "rel.governs names the code a document rules. When that code moves or is deleted the document is orphaned; update governs, move the document with `spec mv`, or archive it.",
on_commit: false,
}
}
fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
for (i, d) in ctx.corpus.docs.iter().enumerate() {
if !ctx.scope.covers(i) || skipped(d, "L5") {
continue;
}
for g in d.rel().remove("governs").unwrap_or_default() {
let base = g.split(['*', '?', '[', '{']).next().unwrap_or("").trim_end_matches('/');
let base = if g.contains(['*', '?', '[', '{']) {
base.rsplit_once('/').map(|x| x.0).unwrap_or(base)
} else {
base
};
if !base.is_empty() && !ctx.corpus.cfg.root.join(base).exists() {
out.push(Finding::warn(
"L5",
&d.path,
key_line(ctx, &d.path, "rel"),
format!("governs '{g}' but {base} does not exist"),
));
}
}
}
}
}