Skip to main content

borg/
expert.rs

1//! L2 of the expertise ladder: a version-keyed expert index of another repo (PLAN.md, "Expertise
2//! and the queen").
3//!
4//! `borg debug expert <repo> --at <commit> [--version <v>] --q "<text>"` answers with at most 3 cited
5//! claims from the expert repo *as it was at `<commit>`*: every file is read through `git ls-tree`
6//! and `git show <commit>:<path>`, never the working tree, so nothing written later can leak in.
7//! Git runs with the caller's GIT_DIR / GIT_WORK_TREE / index / object overrides removed and with
8//! replace refs disabled, at the repo's top level. A ceiling (`BORG_EXPERT_CEILING`, set by the eval
9//! to the expert commit) makes `--at` a hex hash that must be an ancestor of the ceiling.
10//!
11//! Sources, parsed deterministically (0 tokens):
12//! - spec requirements: headings `#### CORE-073: Title` in `spec/**.md` / `specs/**.md`, with their text;
13//! - release notes: `CHANGELOG.md` (or CHANGES / HISTORY / RELEASES) at the root, one claim per change;
14//! - proof coverage: any `*proof-coverage.json` (`theorems[].{name, module, specIds[].{id, fragment}}`);
15//! - docs: every heading of every tracked `README.md` and `docs/**.md`, with its first paragraph.
16//!
17//! Retrieval is BM25 over the claim text (the conscience's tokeniser). With `--version`:
18//! - a claim that states "since vX" / "until vY" is kept only if the pinned version lies in that range;
19//! - a requirement, or a theorem proving one, whose id the changelog first cites in a release newer
20//!   than the pin (or only in an unreleased section) is dropped: it did not exist for the consumer;
21//! - releases newer than the pinned one are surfaced as "newer release vY: <change>": one slot of
22//!   the three is kept for the best-matching newer change, or, if none matches, for a line saying
23//!   that newer releases exist.
24//!
25//! A version is `9.0.0`, `v9`, or labelled, `java 9.0.0` / `rust@11`. An unlabelled pin takes the
26//! changelog's only line; when the changelog names several lines (flowlib: Java, TypeScript, Rust,
27//! Python) the label is required. Sections headed "unreleased", or undated above dated releases,
28//! are printed as "unreleased (in progress at <short>)", never as a newer release.
29
30use std::cmp::Ordering;
31use std::collections::{BTreeSet, HashMap};
32use std::path::{Path, PathBuf};
33use std::process::Command;
34use std::sync::LazyLock;
35
36use regex::Regex;
37
38use crate::conscience::{bm25, tokens};
39
40/// At most this many claims are printed.
41pub const MAX_CLAIMS: usize = 3;
42/// Characters of claim text per line; three lines with their citations stay near 200 tokens.
43const CLAIM_CHARS: usize = 210;
44/// Words of a claim's body that are indexed: a long requirement would otherwise match everything.
45const INDEX_WORDS: usize = 160;
46/// Environment variable naming the newest commit `--at` may resolve to.
47pub const CEILING_ENV: &str = "BORG_EXPERT_CEILING";
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum Kind {
51    Spec,
52    Release { newer: bool },
53    Proof,
54    Doc,
55}
56
57/// One citable statement of the expert repo.
58#[derive(Debug, Clone)]
59pub struct Claim {
60    /// Path of the file it was read from, at the commit.
61    pub source: String,
62    /// Anchor inside the source: requirement id, release and change number, theorem, heading slug.
63    pub id: String,
64    /// The claim as printed (before truncation).
65    pub text: String,
66    /// What BM25 matches against.
67    index: String,
68    /// Requirement ids the claim is about (a spec's own id, a theorem's specIds).
69    reqs: Vec<String>,
70    pub kind: Kind,
71    /// A change of a release that ships the pin's own artifact line above the pin.
72    line_newer: bool,
73}
74
75impl Claim {
76    /// `source#id`, as a rendered line cites it.
77    pub fn key(&self) -> String {
78        format!("{}#{}", self.source, self.id)
79    }
80}
81
82/// The answer: the repo's name, the commit's short hash, and the chosen claims, best first.
83#[derive(Debug)]
84pub struct Answer {
85    pub repo: String,
86    pub short: String,
87    pub claims: Vec<Claim>,
88}
89
90impl Answer {
91    /// `[<repo>@<short> <source>#<id>] <claim>` lines.
92    pub fn render(&self) -> String {
93        self.claims.iter().map(|c| format!("[{}@{} {}#{}] {}\n", self.repo, self.short, c.source, c.id, clip(&c.text, CLAIM_CHARS))).collect()
94    }
95}
96
97static HEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[0-9a-fA-F]{4,40}$").unwrap());
98
99/// `borg debug expert <repo> --at <commit> [--version <v>] [--q <text>]` (the question on stdin without
100/// `--q`).
101pub fn main(repo: &str, rest: &[&str]) -> i32 {
102    let flag = |f: &str| rest.iter().position(|a| *a == f).and_then(|i| rest.get(i + 1)).copied();
103    let Some(at) = flag("--at") else {
104        eprintln!("borg debug expert: --at <commit> is required");
105        return 2;
106    };
107    let query = flag("--q").map_or_else(
108        || {
109            let mut q = String::new();
110            let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut q);
111            q
112        },
113        str::to_string,
114    );
115    match ask(repo.as_ref(), at, flag("--version"), &query, std::env::var(CEILING_ENV).ok().filter(|c| !c.is_empty()).as_deref()) {
116        Ok(answer) => {
117            print!("{}", answer.render());
118            0
119        }
120        Err(e) => {
121            eprintln!("borg debug expert: {e}");
122            1
123        }
124    }
125}
126
127/// Ask the expert repo at `at` (any commit-ish), for a consumer pinned at `version`. With a
128/// `ceiling`, `at` must be a hex hash naming the ceiling or one of its ancestors.
129pub fn ask(repo: &Path, at: &str, version: Option<&str>, query: &str, ceiling: Option<&str>) -> Result<Answer, String> {
130    Ok(Indexed::open(repo, at, version, ceiling)?.answer(query))
131}
132
133/// The expert repo indexed once at a commit, for a consumer pinned at a version: ask it any number
134/// of questions, or read its API vocabulary first (see [`plan_query`]).
135#[derive(Debug)]
136pub struct Indexed {
137    pub repo: String,
138    pub short: String,
139    claims: Vec<Claim>,
140    pinned: bool,
141    /// The work tree's top level and the full commit: [`Indexed::api`] reads the code there.
142    top: PathBuf,
143    commit: String,
144}
145
146impl Indexed {
147    /// See [`ask`].
148    pub fn open(repo: &Path, at: &str, version: Option<&str>, ceiling: Option<&str>) -> Result<Indexed, String> {
149        let top = toplevel(repo);
150        let commit = resolve(&top, at)?;
151        if let Some(ceiling) = ceiling {
152            if !HEX.is_match(at) {
153                return Err(format!("--at {at:?} must be a commit hash while a ceiling is set"));
154            }
155            let ceiling = resolve(&top, ceiling)?;
156            if !is_ancestor(&top, &commit, &ceiling)? {
157                return Err(format!("--at {at} is not at or before the ceiling {}", &ceiling[..ceiling.len().min(12)]));
158            }
159        }
160        let short = git(&top, &["rev-parse", "--short", &commit])?.trim().to_string();
161        let name = top.canonicalize().ok().and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())).unwrap_or_else(|| "repo".into());
162        let pin = match version {
163            Some(v) => Some(Pin::parse(v).ok_or_else(|| format!("cannot read version {v:?}"))?),
164            None => None,
165        };
166        let claims = index(&top, &commit, pin.as_ref())?;
167        Ok(Indexed { repo: name, short, claims, pinned: pin.is_some(), top, commit })
168    }
169
170    /// At most `MAX_CLAIMS` claims for `query`, best first (see [`select`]).
171    pub fn answer(&self, query: &str) -> Answer {
172        self.answer_except(query, &|_| false)
173    }
174
175    /// [`Indexed::answer`] without the claims whose [`Claim::key`] `told` names: what this drone
176    /// was already told is never repeated.
177    pub fn answer_except(&self, query: &str, told: &dyn Fn(&str) -> bool) -> Answer {
178        Answer { repo: self.repo.clone(), short: self.short.clone(), claims: select(self.claims.clone(), self.pinned, query, told) }
179    }
180
181    /// The repo's API vocabulary: code identifiers its claims name (`PetriNet`, `deadlockFree`,
182    /// `fire_transition`) that its own code at the commit declares, so a plan that uses the API
183    /// without naming the repo still finds it. Prose about other code (`HashMap`, `created_at`,
184    /// `CompletableFuture` in a changelog) is no API of the repo: an identifier counts only when the
185    /// repo declares it and it is no well-known standard-library name. No code, no API.
186    pub fn api(&self) -> Api {
187        let mut named = BTreeSet::new();
188        for c in &self.claims {
189            for t in IDENT.find_iter(&c.text).chain(IDENT.find_iter(&c.index)) {
190                if is_api(t.as_str()) && !STD_API.contains(&t.as_str()) {
191                    named.insert(t.as_str().to_string());
192                }
193            }
194        }
195        declarations(&self.top, Some(&self.commit), &named)
196    }
197}
198
199/// Identifiers a repo declares: types (`class PetriNet`, `struct Marking`, `interface TokenSink`)
200/// and members (`fn fire_transition`, `def deadlock_free`, `public boolean deadlockFree(`).
201#[derive(Debug, Clone, Default, PartialEq)]
202pub struct Api {
203    pub types: BTreeSet<String>,
204    pub members: BTreeSet<String>,
205}
206
207impl Api {
208    /// Whether `sentence` uses this API: it names one of its types, or two distinct members (one
209    /// member name alone, `getState` or `close_all`, is too likely the consumer's own).
210    pub fn used_in(&self, sentence: &str) -> bool {
211        let words: BTreeSet<&str> = IDENT.find_iter(sentence).map(|m| m.as_str()).collect();
212        words.iter().any(|w| self.types.contains(*w)) || words.iter().filter(|w| self.members.contains(**w)).count() >= 2
213    }
214
215    /// Without the identifiers the consumer in `dir` declares itself: its own `PetriNet` is not the
216    /// expert's. Only the identifiers `plan` uses are looked up, so this stays cheap.
217    pub fn without_declared_in(mut self, dir: &Path, plan: &str) -> Api {
218        let used: BTreeSet<String> =
219            IDENT.find_iter(plan).map(|m| m.as_str().to_string()).filter(|w| self.types.contains(w) || self.members.contains(w)).collect();
220        if used.is_empty() || dir.as_os_str().is_empty() {
221            return self;
222        }
223        let own = declarations(&toplevel(dir), None, &used);
224        let theirs = |t: &String| !own.types.contains(t) && !own.members.contains(t);
225        self.types.retain(theirs);
226        self.members.retain(theirs);
227        self
228    }
229}
230
231/// Source files whose declarations make up an API.
232const CODE: [&str; 13] = ["*.java", "*.kt", "*.scala", "*.ts", "*.tsx", "*.js", "*.mjs", "*.rs", "*.py", "*.go", "*.cs", "*.swift", "*.lean"];
233
234/// Every keyword `TYPE_DECL` and `MEMBER_DECL` start at (POSIX ERE, for git grep).
235const DECL_WORDS: &str = "(class|interface|enum|record|struct|trait|object|type|protocol|structure|inductive|fn|fun|func|def|function|theorem|lemma|export|public|protected)[[:space:]]";
236
237static TYPE_DECL: LazyLock<Regex> = LazyLock::new(|| {
238    Regex::new(r"\b(?:class|interface|enum|record|struct|trait|object|type|protocol|structure|inductive)\s+([A-Za-z_][A-Za-z0-9_]*)").unwrap()
239});
240static MEMBER_DECL: LazyLock<Regex> = LazyLock::new(|| {
241    Regex::new(concat!(
242        r"\b(?:fn|fun|func|def|function|theorem|lemma)\s+([A-Za-z_][A-Za-z0-9_]*)",
243        r"|\bexport\s+(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)",
244        r"|\b(?:public|protected)\b[^=;(){}]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\(",
245    ))
246    .unwrap()
247});
248
249/// Which of `names` the code of `top` declares: at `rev`, or in the tracked work tree when None.
250/// Any git failure (not a repo, no match) declares nothing.
251fn declarations(top: &Path, rev: Option<&str>, names: &BTreeSet<String>) -> Api {
252    let mut api = Api::default();
253    if names.is_empty() {
254        return api;
255    }
256    // Lines that may declare something are read, then checked for a declaration of one of `names`.
257    // A few names are looked up as words; many (a repo's whole vocabulary) by the declaring keyword,
258    // because hundreds of word patterns make git grep take tens of seconds on a large repo.
259    let mut args: Vec<&str> = vec!["grep", "-h", "-I"];
260    if names.len() <= 16 {
261        args.extend(["-w", "-F"]);
262        for n in names {
263            args.extend(["-e", n.as_str()]);
264        }
265    } else {
266        args.extend(["-E", "-e", DECL_WORDS]);
267    }
268    args.extend(rev);
269    args.push("--");
270    args.extend(CODE);
271    let Ok(out) = git(top, &args) else { return api };
272    for line in out.lines() {
273        for c in TYPE_DECL.captures_iter(line).filter(|c| names.contains(&c[1])) {
274            api.types.insert(c[1].to_string());
275        }
276        for c in MEMBER_DECL.captures_iter(line) {
277            if let Some(m) = c.get(1).or(c.get(2)).or(c.get(3)).filter(|m| names.contains(m.as_str())) {
278                api.members.insert(m.as_str().to_string());
279            }
280        }
281    }
282    let types = api.types.clone();
283    api.members.retain(|m| !types.contains(m));
284    api
285}
286
287static IDENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_]*").unwrap());
288/// Words with an inner capital that are prose, not API.
289const NOT_API: [&str; 8] = ["JavaScript", "TypeScript", "GitHub", "GitLab", "macOS", "iOS", "README", "CHANGELOG"];
290
291/// Standard-library and everyday names (JDK, Rust std, JS/TS, Python, schema columns) that a repo
292/// may mention, wrap or even redeclare, but that never say a plan is about that repo.
293const STD_API: &[&str] = &[
294    // Java
295    "HashMap",
296    "LinkedHashMap",
297    "TreeMap",
298    "ConcurrentHashMap",
299    "ConcurrentMap",
300    "EnumMap",
301    "IdentityHashMap",
302    "WeakHashMap",
303    "HashSet",
304    "LinkedHashSet",
305    "TreeSet",
306    "EnumSet",
307    "ArrayList",
308    "LinkedList",
309    "ArrayDeque",
310    "PriorityQueue",
311    "BlockingQueue",
312    "LinkedBlockingQueue",
313    "ArrayBlockingQueue",
314    "ConcurrentLinkedQueue",
315    "CopyOnWriteArrayList",
316    "CompletableFuture",
317    "CompletionStage",
318    "ExecutorService",
319    "ScheduledExecutorService",
320    "ThreadPoolExecutor",
321    "ForkJoinPool",
322    "CountDownLatch",
323    "CyclicBarrier",
324    "AtomicInteger",
325    "AtomicLong",
326    "AtomicBoolean",
327    "AtomicReference",
328    "ReentrantLock",
329    "ReadWriteLock",
330    "ReentrantReadWriteLock",
331    "ThreadLocal",
332    "StringBuilder",
333    "StringBuffer",
334    "BigDecimal",
335    "BigInteger",
336    "LocalDate",
337    "LocalDateTime",
338    "LocalTime",
339    "ZonedDateTime",
340    "OffsetDateTime",
341    "ZoneId",
342    "ChronoUnit",
343    "TimeUnit",
344    "InputStream",
345    "OutputStream",
346    "ByteBuffer",
347    "CharSequence",
348    "IntStream",
349    "LongStream",
350    "AutoCloseable",
351    "FunctionalInterface",
352    "IllegalArgumentException",
353    "IllegalStateException",
354    "InterruptedException",
355    "RuntimeException",
356    "NullPointerException",
357    "IOException",
358    "UncheckedIOException",
359    "TimeoutException",
360    "ExecutionException",
361    "CancellationException",
362    "UnsupportedOperationException",
363    "IndexOutOfBoundsException",
364    "ClassCastException",
365    "ConcurrentModificationException",
366    "NoSuchElementException",
367    "NumberFormatException",
368    "StackOverflowError",
369    "OutOfMemoryError",
370    "SuppressWarnings",
371    "toString",
372    "hashCode",
373    "compareTo",
374    "getClass",
375    "forEach",
376    "valueOf",
377    "orElse",
378    "orElseThrow",
379    "orElseGet",
380    "isEmpty",
381    "isPresent",
382    "ifPresent",
383    "ofNullable",
384    "getMessage",
385    "getName",
386    "getValue",
387    "getKey",
388    "setName",
389    "setValue",
390    "thenApply",
391    "thenCompose",
392    "thenAccept",
393    "supplyAsync",
394    "runAsync",
395    "parseInt",
396    "toList",
397    // Rust
398    "BTreeMap",
399    "BTreeSet",
400    "VecDeque",
401    "BinaryHeap",
402    "IntoIterator",
403    "FromIterator",
404    "DoubleEndedIterator",
405    "ExactSizeIterator",
406    "AsRef",
407    "AsMut",
408    "ToString",
409    "ToOwned",
410    "PartialEq",
411    "PartialOrd",
412    "DefaultHasher",
413    "JoinHandle",
414    "RwLock",
415    "OnceCell",
416    "OnceLock",
417    "LazyLock",
418    "LazyCell",
419    "RefCell",
420    "PhantomData",
421    "PathBuf",
422    "OsString",
423    "SystemTime",
424    "TryFrom",
425    "TryInto",
426    "FromStr",
427    "into_iter",
428    "iter_mut",
429    "unwrap_or",
430    "unwrap_or_else",
431    "unwrap_or_default",
432    "as_str",
433    "as_ref",
434    "as_mut",
435    "to_string",
436    "to_owned",
437    "to_vec",
438    "from_str",
439    "ok_or",
440    "ok_or_else",
441    "and_then",
442    "map_err",
443    "is_empty",
444    "is_some",
445    "is_none",
446    "with_capacity",
447    "read_to_string",
448    "spawn_blocking",
449    // JS / TS
450    "setTimeout",
451    "setInterval",
452    "clearTimeout",
453    "clearInterval",
454    "addEventListener",
455    "removeEventListener",
456    "toFixed",
457    "getElementById",
458    "querySelector",
459    "querySelectorAll",
460    "AbortController",
461    "AbortSignal",
462    "ReadableStream",
463    "WritableStream",
464    "WeakMap",
465    "WeakSet",
466    "WeakRef",
467    "ArrayBuffer",
468    "Uint8Array",
469    "EventEmitter",
470    "EventTarget",
471    "TypeError",
472    "RangeError",
473    "SyntaxError",
474    "ReferenceError",
475    "useState",
476    "useEffect",
477    "useMemo",
478    "useCallback",
479    "useRef",
480    "toISOString",
481    "localStorage",
482    "sessionStorage",
483    "innerHTML",
484    "textContent",
485    "readFileSync",
486    "writeFileSync",
487    // Python
488    "__init__",
489    "__name__",
490    "__main__",
491    "__repr__",
492    "__str__",
493    "__eq__",
494    "__hash__",
495    "__call__",
496    "__enter__",
497    "__exit__",
498    "ValueError",
499    "KeyError",
500    "IndexError",
501    "RuntimeError",
502    "TypeVar",
503    "NamedTuple",
504    "OrderedDict",
505    "defaultdict",
506    // Columns and fields every schema has
507    "created_at",
508    "updated_at",
509    "deleted_at",
510    "user_id",
511    "created_by",
512    "updated_by",
513    "createdAt",
514    "updatedAt",
515    "deletedAt",
516    "userId",
517    "timeoutMs",
518    "timeout_ms",
519];
520
521/// An identifier that can only be code: camelCase / PascalCase with an inner capital, or snake_case.
522fn is_api(t: &str) -> bool {
523    let b = t.as_bytes();
524    let inner_capital = b.windows(2).any(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase());
525    let snake = t.trim_matches('_').contains('_') && t.chars().any(|c| c.is_ascii_lowercase());
526    t.len() >= 5 && (inner_capital || snake) && !NOT_API.contains(&t)
527}
528
529/// What to ask the expert about a plan: the plan's sentences that mention the dependency, by one of
530/// `names` (case-insensitive, whole words; `org.flowlib` also matches `org.flowlib:flowlib`) or by
531/// its `api` (case-sensitive; see [`Api::used_in`]). The query is those sentences plus the names,
532/// never the whole plan: a long plan otherwise drowns the dependency in unrelated words (E1). None
533/// when no sentence mentions it: the plan does not touch the dependency, so nothing is asked.
534pub fn plan_query(plan: &str, names: &[&str], api: &Api) -> Option<(Vec<String>, String)> {
535    let names: Vec<String> = names.iter().map(|n| n.trim().to_lowercase()).filter(|n| !n.is_empty()).collect();
536    let mentions = |s: &str| {
537        let low = s.to_lowercase();
538        names.iter().any(|n| has_word(&low, n)) || api.used_in(s)
539    };
540    let picked: Vec<String> = sentences(plan).into_iter().filter(|s| mentions(s)).map(str::to_string).collect();
541    if picked.is_empty() {
542        return None;
543    }
544    let query = format!("{} {}", picked.join(" "), names.join(" "));
545    Some((picked, query))
546}
547
548/// `needle` occurs in `hay` with no letter, digit or '_' right before or after it.
549fn has_word(hay: &str, needle: &str) -> bool {
550    let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
551    hay.match_indices(needle).any(|(i, _)| !word(hay[..i].chars().next_back()) && !word(hay[i + needle.len()..].chars().next()))
552}
553
554/// A plan's sentences: its lines, split again after '.', '?' or '!' followed by a space once at
555/// least three words precede it.
556fn sentences(plan: &str) -> Vec<&str> {
557    let mut out = Vec::new();
558    for line in plan.lines() {
559        let b = line.as_bytes();
560        let mut start = 0;
561        for i in 0..b.len() {
562            // "2. ", "e.g. " or "Fix it. " is no sentence of its own: it stays with what follows.
563            if matches!(b[i], b'.' | b'?' | b'!') && b.get(i + 1) == Some(&b' ') && line[start..i].split_whitespace().count() >= 3 {
564                out.push(line[start..=i].trim());
565                start = i + 1;
566            }
567        }
568        out.push(line[start..].trim());
569    }
570    out.retain(|s| !s.is_empty());
571    out
572}
573
574/// The work tree's top level, so a subdirectory path reads the whole repo; a bare repo stays as is.
575fn toplevel(repo: &Path) -> PathBuf {
576    git(repo, &["rev-parse", "--show-toplevel"]).map(|t| PathBuf::from(t.trim())).unwrap_or_else(|_| repo.to_path_buf())
577}
578
579fn resolve(repo: &Path, rev: &str) -> Result<String, String> {
580    Ok(git(repo, &["rev-parse", "--verify", "--end-of-options", &format!("{rev}^{{commit}}")])?.trim().to_string())
581}
582
583/// All claims of the repo at `commit`. The pin is resolved once against the changelog (its line
584/// made explicit) and then used everywhere: release claims carry `newer` relative to it; spec, doc
585/// and proof claims whose stated range excludes it, or whose requirement arrived later, are dropped.
586pub fn index(repo: &Path, commit: &str, pin: Option<&Pin>) -> Result<Vec<Claim>, String> {
587    let paths = git(repo, &["ls-tree", "-r", "--full-tree", "--name-only", commit])?;
588    let short = git(repo, &["rev-parse", "--short", commit])?.trim().to_string();
589    let show = |path: &str| git(repo, &["show", &format!("{commit}:{path}")]);
590    let mut claims = Vec::new();
591    let mut log: Option<(String, String)> = None;
592    for path in paths.lines() {
593        let lower = path.to_lowercase();
594        let base = lower.rsplit('/').next().unwrap_or_default();
595        let is_md = lower.ends_with(".md");
596        if is_md && (lower.starts_with("spec/") || lower.starts_with("specs/")) {
597            claims.extend(spec_claims(path, &show(path)?));
598        } else if !lower.contains('/') && is_md && ["changelog", "changes", "history", "releases"].iter().any(|n| base.starts_with(n)) {
599            if log.is_none() {
600                log = Some((path.to_string(), show(path)?));
601            }
602        } else if base.ends_with("proof-coverage.json") {
603            claims.extend(proof_claims(path, &show(path)?));
604        } else if is_md && (base == "readme.md" || lower.starts_with("docs/")) {
605            claims.extend(doc_claims(path, &show(path)?));
606        }
607    }
608    let releases = log.as_ref().map(|(_, t)| parse_releases(t)).unwrap_or_default();
609    let pin = pin.map(|p| resolve_pin(p, &releases)).transpose()?;
610    let newer = newer_flags(&releases, pin.as_ref());
611    if let Some(pin) = &pin {
612        let late = late_reqs(&releases, &newer);
613        claims.retain(|c| in_range(&c.index, pin) && !c.reqs.iter().any(|r| late.contains(r.as_str())));
614    }
615    if let Some((path, _)) = &log {
616        claims.extend(release_claims(path, &releases, &newer, pin.as_ref(), &short));
617    }
618    Ok(claims)
619}
620
621/// A newer-release change takes the reserved slot only if it scores at least this share of the best
622/// claim; a weaker one is noise, and the slot says instead that newer releases exist.
623const NEWER_MIN_SHARE: f64 = 0.25;
624
625/// BM25 top claims with score > 0, at most `MAX_CLAIMS`. With a pin:
626/// - when releases of the pin's own line newer than the pin exist, the first claim always says so
627///   ("N newer release(s) than the pinned version: ..."): for a consumer it is the most consequential
628///   fact, whatever the query (E1 missed "Java 10.0.0 released" when asked with a plan);
629/// - one of the remaining slots shows the best-matching newer change, if it scores at least
630///   `NEWER_MIN_SHARE` of the best claim; with newer releases only of other lines and no matching
631///   newer change, the last slot says that those exist.
632///
633/// Claims `told` names by key are left out, the newer-release line too: once told, never repeated.
634fn select(claims: Vec<Claim>, pinned: bool, query: &str, told: &dyn Fn(&str) -> bool) -> Vec<Claim> {
635    let docs: Vec<Vec<String>> = claims.iter().map(|c| tokens(&c.index)).collect();
636    let mut ranked: Vec<(f64, usize)> =
637        bm25(&docs, query).into_iter().enumerate().map(|(i, s)| (s, i)).filter(|&(s, i)| s > 0.0 && !told(&claims[i].key())).collect();
638    ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
639    let newer = |i: usize| claims[i].kind == Kind::Release { newer: true };
640    let lead = (pinned && claims.iter().any(|c| c.line_newer)).then(|| newer_summary(&claims, |c| c.line_newer)).filter(|c| !told(&c.key()));
641    let room = MAX_CLAIMS - usize::from(lead.is_some());
642    let mut chosen: Vec<usize> = ranked.iter().map(|r| r.1).take(room).collect();
643    let mut tail = None;
644    let floor = ranked.first().map_or(0.0, |r| r.0 * NEWER_MIN_SHARE);
645    if pinned && !chosen.iter().any(|&i| newer(i)) {
646        if let Some(&(_, i)) = ranked.iter().find(|r| newer(r.1) && r.0 >= floor) {
647            chosen.truncate(room - 1);
648            chosen.push(i);
649        } else if lead.is_none() && claims.iter().any(newer_release_first) {
650            let summary = newer_summary(&claims, |_| true);
651            if !told(&summary.key()) {
652                chosen.truncate(room - 1);
653                tail = Some(summary);
654            }
655        }
656    }
657    lead.into_iter().chain(chosen.into_iter().map(|i| claims[i].clone())).chain(tail).collect()
658}
659
660fn newer_release_first(c: &Claim) -> bool {
661    c.kind == Kind::Release { newer: true } && c.id.ends_with(":1")
662}
663
664/// "N newer release(s) than the pinned version: A (date); B (date)" over the newer releases `keep`
665/// admits (at least one), cited to the newest of them.
666fn newer_summary(claims: &[Claim], keep: impl Fn(&Claim) -> bool) -> Claim {
667    let firsts: Vec<&Claim> = claims.iter().filter(|c| newer_release_first(c) && keep(c)).collect();
668    let names: Vec<String> = firsts.iter().map(|c| c.text.split(':').next().unwrap_or_default().trim_start_matches("newer release ").to_string()).collect();
669    let c = firsts[0];
670    Claim {
671        source: c.source.clone(),
672        id: c.id.trim_end_matches(":1").to_string(),
673        text: format!("{} newer release(s) than the pinned version: {}", names.len(), names.join("; ")),
674        index: String::new(),
675        reqs: Vec::new(),
676        kind: c.kind.clone(),
677        line_newer: false,
678    }
679}
680
681/// Git variables that would point `-C <repo>` at another repository, index or object store.
682const GIT_ENV: [&str; 7] =
683    ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_COMMON_DIR"];
684
685fn git_cmd(repo: &Path) -> Command {
686    let mut cmd = Command::new("git");
687    for v in GIT_ENV {
688        cmd.env_remove(v);
689    }
690    // refs/replace could swap an old commit's content for something written later.
691    cmd.env("GIT_NO_REPLACE_OBJECTS", "1").arg("-C").arg(repo);
692    cmd
693}
694
695fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
696    let out = git_cmd(repo).args(args).output().map_err(|e| format!("git: {e}"))?;
697    if !out.status.success() {
698        return Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
699    }
700    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
701}
702
703/// Whether `a` is `b` or an ancestor of it.
704fn is_ancestor(repo: &Path, a: &str, b: &str) -> Result<bool, String> {
705    let out = git_cmd(repo).args(["merge-base", "--is-ancestor", a, b]).output().map_err(|e| format!("git: {e}"))?;
706    match out.status.code() {
707        Some(0) => Ok(true),
708        Some(1) => Ok(false),
709        _ => Err(format!("git merge-base --is-ancestor: {}", String::from_utf8_lossy(&out.stderr).trim())),
710    }
711}
712
713// ---------------------------------------------------------------- versions
714
715/// A version as numbers, compared with missing parts as 0 (`10` == `10.0.0`).
716#[derive(Debug, Clone)]
717pub struct Ver(Vec<u64>);
718
719impl Ver {
720    fn parse(s: &str) -> Option<Ver> {
721        let v: Option<Vec<u64>> = s.trim_start_matches(['v', 'V']).split('.').map(|p| p.parse().ok()).collect();
722        v.filter(|v| !v.is_empty()).map(Ver)
723    }
724}
725
726impl PartialEq for Ver {
727    fn eq(&self, o: &Self) -> bool {
728        self.cmp(o) == Ordering::Equal
729    }
730}
731impl Eq for Ver {}
732impl PartialOrd for Ver {
733    fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
734        Some(self.cmp(o))
735    }
736}
737impl Ord for Ver {
738    fn cmp(&self, o: &Self) -> Ordering {
739        let n = self.0.len().max(o.0.len());
740        (0..n).map(|i| self.0.get(i).unwrap_or(&0).cmp(o.0.get(i).unwrap_or(&0))).find(|c| c.is_ne()).unwrap_or(Ordering::Equal)
741    }
742}
743
744/// The version the consumer pins, optionally labelled with the artifact line (`java`, `rust`).
745#[derive(Debug, Clone)]
746pub struct Pin {
747    pub label: Option<String>,
748    pub ver: Ver,
749}
750
751static PIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*(?:([a-z][a-z+#]*)[\s@:/=]+)?v?(\d+(?:\.\d+){0,3})\s*$").unwrap());
752
753impl Pin {
754    pub fn parse(s: &str) -> Option<Pin> {
755        let c = PIN.captures(s)?;
756        Some(Pin { label: c.get(1).map(|l| l.as_str().to_lowercase()), ver: Ver::parse(&c[2])? })
757    }
758}
759
760/// Same artifact line: either side unlabelled, or equal labels.
761fn same_line(label: Option<&str>, pin: &Pin) -> bool {
762    match (label, &pin.label) {
763        (Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
764        _ => true,
765    }
766}
767
768static SINCE: LazyLock<Regex> = LazyLock::new(|| {
769    Regex::new(r"(?i)\b(since|as of|added in|introduced in|until|before|removed in)\s+(?:([a-z][a-z+#]*)\s+)?v?(\d+\.\d+(?:\.\d+)?)\b").unwrap()
770});
771
772/// Whether every "since vX" / "until vY" the text states admits the pinned version.
773fn in_range(text: &str, pin: &Pin) -> bool {
774    SINCE.captures_iter(text).all(|c| {
775        let Some(v) = Ver::parse(&c[3]) else { return true };
776        if !same_line(c.get(2).map(|m| m.as_str()), pin) {
777            return true;
778        }
779        match c[1].to_lowercase().as_str() {
780            "until" | "before" | "removed in" => pin.ver < v,
781            _ => pin.ver >= v,
782        }
783    })
784}
785
786// ---------------------------------------------------------------- sources
787
788static REQ: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{2,6}\s+\[?([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+)\]?\s*[:.\u{2014}\u{2013}-]\s*(.+?)\s*$").unwrap());
789static HEADING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.+?)\s*#*\s*$").unwrap());
790
791/// Requirement headings and their text up to the next heading or rule; the priority line is dropped.
792fn spec_claims(path: &str, text: &str) -> Vec<Claim> {
793    let mut out = Vec::new();
794    let mut cur: Option<(String, String, Vec<&str>)> = None;
795    let flush = |cur: &mut Option<(String, String, Vec<&str>)>, out: &mut Vec<Claim>| {
796        if let Some((id, title, body)) = cur.take() {
797            let body = body.join("\n");
798            let first = paragraphs(&body).into_iter().find(|p| !p.starts_with("Priority:")).unwrap_or_default();
799            out.push(Claim {
800                source: path.into(),
801                id: id.clone(),
802                text: format!("{id} {title}: {first}"),
803                index: format!("{id} {title} {title} {}", words(&plain(&body), INDEX_WORDS)),
804                reqs: vec![id.clone()],
805                kind: Kind::Spec,
806                line_newer: false,
807            });
808        }
809    };
810    for line in text.lines() {
811        if let Some(c) = REQ.captures(line) {
812            flush(&mut cur, &mut out);
813            cur = Some((c[1].to_string(), plain(&c[2]), Vec::new()));
814        } else if line.starts_with('#') || line.trim() == "---" {
815            flush(&mut cur, &mut out);
816        } else if let Some((_, _, body)) = cur.as_mut() {
817            body.push(line);
818        }
819    }
820    flush(&mut cur, &mut out);
821    out
822}
823
824static RELEASE_PART: LazyLock<Regex> =
825    LazyLock::new(|| Regex::new(r"(?:\b([A-Za-z][A-Za-z+#]*)\s+)?\[?v?(\d+\.\d+(?:\.\d+)?)\]?(?:\s*\(([A-Za-z][A-Za-z+#]*)\))?").unwrap());
826static DATE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap());
827/// A requirement id cited in a change, `[CORE-002]` or `VER-024`.
828static REQ_ID: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+\b").unwrap());
829
830#[derive(Debug)]
831struct Release<'a> {
832    /// (label, version) of each artifact released together, e.g. ("java", 10.0.0), ("rust", 11.0.0).
833    parts: Vec<(Option<String>, Ver)>,
834    title: String,
835    date: Option<String>,
836    /// Headed "unreleased", or undated above dated releases: not installable yet.
837    unreleased: bool,
838    body: Vec<&'a str>,
839}
840
841/// The `## <version>` sections of a changelog, newest first.
842fn parse_releases(text: &str) -> Vec<Release<'_>> {
843    let mut releases: Vec<Release> = Vec::new();
844    let mut in_release = false;
845    for line in text.lines() {
846        if let Some(h) = line.strip_prefix("## ") {
847            let h = h.trim();
848            let date = DATE.find(h).map(|m| m.as_str().to_string());
849            let head = DATE.find(h).map_or(h, |m| &h[..m.start()]);
850            let parts: Vec<(Option<String>, Ver)> = RELEASE_PART
851                .captures_iter(head)
852                .filter_map(|c| {
853                    let label = c.get(1).or(c.get(3)).map(|l| l.as_str().to_lowercase()).filter(|l| !["v", "version", "unreleased"].contains(&l.as_str()));
854                    Some((label, Ver::parse(&c[2])?))
855                })
856                .collect();
857            in_release = !parts.is_empty();
858            if in_release {
859                let title = head.trim().trim_end_matches(['-', '\u{2014}', '\u{2013}', ' ']).trim_matches(['[', ']']).to_string();
860                let unreleased = h.to_lowercase().contains("unreleased");
861                releases.push(Release { parts, title, date, unreleased, body: Vec::new() });
862            }
863        } else if in_release && let Some(r) = releases.last_mut() {
864            r.body.push(line);
865        }
866    }
867    // Undated sections above the first dated release are work in progress.
868    if let Some(dated) = releases.iter().position(|r| r.date.is_some()) {
869        releases[..dated].iter_mut().for_each(|r| r.unreleased = true);
870    }
871    releases
872}
873
874/// Make the pin's artifact line explicit. An unlabelled pin takes the changelog's only line; when
875/// the released sections name several lines (or labelled and unlabelled ones), the pin is ambiguous.
876fn resolve_pin(pin: &Pin, releases: &[Release]) -> Result<Pin, String> {
877    if pin.label.is_some() {
878        return Ok(pin.clone());
879    }
880    let parts = releases.iter().filter(|r| !r.unreleased).flat_map(|r| &r.parts);
881    let labels: BTreeSet<&str> = parts.clone().filter_map(|(l, _)| l.as_deref()).collect();
882    let unlabelled = parts.clone().any(|(l, _)| l.is_none());
883    match labels.len() {
884        0 => Ok(pin.clone()),
885        1 if !unlabelled => Ok(Pin { label: labels.first().map(|l| l.to_string()), ver: pin.ver.clone() }),
886        _ => {
887            let mut found: Vec<&str> = labels.into_iter().collect();
888            if unlabelled {
889                found.push("(unlabelled)");
890            }
891            Err(format!(
892                "version {} names no artifact line, and the changelog has several: {}; pin as e.g. \"{} {}\"",
893                ver_str(&pin.ver),
894                found.join(", "),
895                found[0],
896                ver_str(&pin.ver)
897            ))
898        }
899    }
900}
901
902/// One claim per change of each release (a top-level bullet or a paragraph), newest release first.
903fn release_claims(path: &str, releases: &[Release], newer: &[bool], pin: Option<&Pin>, short: &str) -> Vec<Claim> {
904    let mut out = Vec::new();
905    for (r, &newer) in releases.iter().zip(newer) {
906        let shown = match pin {
907            Some(p) => r.parts.iter().find(|(l, _)| l.is_some() && same_line(l.as_deref(), p)).map(|(l, v)| (l.as_deref(), v)),
908            None => None,
909        };
910        let shown = match shown.or_else(|| r.unreleased.then(|| r.parts.first().map(|(l, v)| (l.as_deref(), v))).flatten()) {
911            Some((l, v)) => l.map_or_else(|| ver_str(v), |l| format!("{} {}", capitalise(l), ver_str(v))),
912            None => r.title.clone(),
913        };
914        let date = r.date.as_ref().map(|d| format!(" ({d})")).unwrap_or_default();
915        let line_newer = newer && pin.is_some_and(|p| r.parts.iter().any(|(l, v)| same_line(l.as_deref(), p) && v > &p.ver));
916        let lead = if r.unreleased {
917            format!("unreleased {shown} (in progress at {short})")
918        } else if newer {
919            format!("newer release {shown}{date}")
920        } else {
921            format!("release {shown}{date}")
922        };
923        // Anchored by the release's first artifact: `java-10.0.0:3` is the third change of that release.
924        let anchor = r.parts.first().map_or_else(|| slug(&r.title), |(l, v)| slug(&format!("{} {}", l.as_deref().unwrap_or("v"), ver_str(v))));
925        for (n, (section, change)) in changes(&r.body).into_iter().enumerate() {
926            // "### Added" reads "added <change>"; a titled section ("### Added: snapshots") keeps its words.
927            let what = match section.as_str() {
928                "" => String::new(),
929                s if !s.contains(' ') => format!("{} ", s.to_lowercase()),
930                s => format!("{s}: "),
931            };
932            out.push(Claim {
933                source: path.into(),
934                id: format!("{anchor}:{}", n + 1),
935                text: format!("{lead}: {what}{}", first_sentences(&change)),
936                index: format!("{} {section} {}", r.title, words(&change, INDEX_WORDS)),
937                reqs: Vec::new(),
938                kind: Kind::Release { newer },
939                line_newer,
940            });
941        }
942    }
943    out
944}
945
946/// Which releases are newer than the pin. A release that names the pin's line compares versions;
947/// one that does not (e.g. a Rust-only release for a Java pin) is newer iff it is listed above the
948/// newest release at or below the pin (changelogs list newest first). Unreleased sections are never
949/// newer releases: nobody can install them.
950fn newer_flags(releases: &[Release], pin: Option<&Pin>) -> Vec<bool> {
951    let Some(pin) = pin else { return vec![false; releases.len()] };
952    let cmp = |r: &Release| r.parts.iter().find(|(l, _)| same_line(l.as_deref(), pin)).map(|(_, v)| v.cmp(&pin.ver));
953    let pinned = releases.iter().position(|r| !r.unreleased && cmp(r).is_some_and(|o| o.is_le())).unwrap_or(releases.len());
954    releases.iter().enumerate().map(|(i, r)| !r.unreleased && cmp(r).map_or(i < pinned, |o| o.is_gt())).collect()
955}
956
957/// Requirement ids that arrived after the pin: the oldest release citing them is newer than the
958/// pin, or only unreleased sections cite them. Ids the changelog never cites are not judged.
959fn late_reqs<'a>(releases: &'a [Release], newer: &[bool]) -> BTreeSet<&'a str> {
960    // Newest first, so the last write per id is its oldest citing release.
961    let mut first: HashMap<&str, bool> = HashMap::new();
962    for (r, &newer) in releases.iter().zip(newer) {
963        for line in &r.body {
964            for m in REQ_ID.find_iter(line) {
965                first.insert(m.as_str(), newer || r.unreleased);
966            }
967        }
968    }
969    first.into_iter().filter(|&(_, late)| late).map(|(id, _)| id).collect()
970}
971
972/// A release body as (section, change): top-level bullets with their continuation lines, and loose
973/// paragraphs. `### Added` etc. name the section; code blocks are skipped.
974fn changes(body: &[&str]) -> Vec<(String, String)> {
975    let mut out: Vec<(String, String)> = Vec::new();
976    let mut section = String::new();
977    // The change being read, and whether it is a bullet (which survives blank lines) or a paragraph.
978    let mut cur: Option<(String, bool)> = None;
979    let mut fence = false;
980    let mut blank = false;
981    let push = |cur: &mut Option<(String, bool)>, out: &mut Vec<(String, String)>, section: &str| {
982        if let Some(c) = cur.take().map(|c| plain(&c.0)).filter(|c| !c.is_empty()) {
983            out.push((section.to_string(), c));
984        }
985    };
986    for line in body {
987        if line.trim_start().starts_with("```") {
988            fence = !fence;
989            continue;
990        }
991        if fence || line.trim() == "---" {
992            continue;
993        }
994        let indented = line.starts_with(' ') || line.starts_with('\t');
995        if let Some(h) = line.strip_prefix("###") {
996            push(&mut cur, &mut out, &section);
997            section = h.trim_start_matches('#').trim().to_string();
998        } else if line.starts_with("- ") || line.starts_with("* ") {
999            push(&mut cur, &mut out, &section);
1000            cur = Some((line[2..].to_string(), true));
1001        } else if line.trim().is_empty() {
1002            if cur.as_ref().is_some_and(|c| !c.1) {
1003                push(&mut cur, &mut out, &section);
1004            }
1005        } else if let Some(c) = cur.as_mut().filter(|c| indented && c.1 || !blank) {
1006            // An indented line continues a bullet; any line right after text continues it.
1007            c.0.push(' ');
1008            c.0.push_str(line.trim());
1009        } else {
1010            push(&mut cur, &mut out, &section);
1011            cur = Some((line.trim().to_string(), false));
1012        }
1013        blank = line.trim().is_empty();
1014    }
1015    push(&mut cur, &mut out, &section);
1016    out
1017}
1018
1019/// One claim per theorem: what it proves for which requirement, under which fragment.
1020fn proof_claims(path: &str, text: &str) -> Vec<Claim> {
1021    let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
1022    let Some(theorems) = v["theorems"].as_array() else { return Vec::new() };
1023    theorems
1024        .iter()
1025        .filter_map(|t| {
1026            let name = t["name"].as_str()?;
1027            let module = t["module"].as_str().unwrap_or_default();
1028            let ids: Vec<String> = t["specIds"]
1029                .as_array()
1030                .into_iter()
1031                .flatten()
1032                .map(|s| format!("{} ({})", s["id"].as_str().unwrap_or_default(), s["fragment"].as_str().unwrap_or_default()))
1033                .collect();
1034            Some(Claim {
1035                source: path.into(),
1036                id: name.into(),
1037                text: format!("Lean {name} ({module}) proves {}", ids.join("; ")),
1038                index: format!("proof proven lean theorem {name} {module} {}", ids.join(" ")),
1039                reqs: t["specIds"].as_array().into_iter().flatten().filter_map(|s| s["id"].as_str().map(String::from)).collect(),
1040                kind: Kind::Proof,
1041                line_newer: false,
1042            })
1043        })
1044        .collect()
1045}
1046
1047/// Every heading with its first paragraph (code blocks skipped).
1048fn doc_claims(path: &str, text: &str) -> Vec<Claim> {
1049    let mut out = Vec::new();
1050    let mut cur: Option<(String, Vec<&str>)> = None;
1051    let mut fence = false;
1052    let flush = |cur: &mut Option<(String, Vec<&str>)>, out: &mut Vec<Claim>| {
1053        if let Some((title, body)) = cur.take() {
1054            let first = paragraphs(&body.join("\n")).into_iter().next().unwrap_or_default();
1055            let text = if first.is_empty() { title.clone() } else { format!("{title}: {first}") };
1056            out.push(Claim {
1057                source: path.into(),
1058                id: slug(&title),
1059                index: format!("{title} {title} {}", words(&first, INDEX_WORDS)),
1060                text,
1061                reqs: Vec::new(),
1062                kind: Kind::Doc,
1063                line_newer: false,
1064            });
1065        }
1066    };
1067    for line in text.lines() {
1068        if line.trim_start().starts_with("```") {
1069            fence = !fence;
1070            continue;
1071        }
1072        if fence {
1073            continue;
1074        }
1075        if let Some(c) = HEADING.captures(line) {
1076            flush(&mut cur, &mut out);
1077            cur = Some((plain(&c[2]), Vec::new()));
1078        } else if let Some((_, body)) = cur.as_mut() {
1079            body.push(line);
1080        }
1081    }
1082    flush(&mut cur, &mut out);
1083    out
1084}
1085
1086// ---------------------------------------------------------------- text
1087
1088static LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[([^\]]*)\]\([^)]*\)").unwrap());
1089static HTML: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
1090
1091/// Markdown to plain text: links to their text, emphasis and tags removed, whitespace collapsed.
1092fn plain(s: &str) -> String {
1093    let s = LINK.replace_all(s, "$1");
1094    let s = HTML.replace_all(&s, "");
1095    s.replace("**", "").replace("__", "").split_whitespace().collect::<Vec<_>>().join(" ")
1096}
1097
1098/// Paragraphs of a markdown body as plain text, skipping code blocks, tables and images. A paragraph
1099/// ending in ':' runs on into the next one, the list it introduces.
1100fn paragraphs(body: &str) -> Vec<String> {
1101    let mut out = Vec::new();
1102    let mut cur = String::new();
1103    let mut fence = false;
1104    for line in body.lines() {
1105        let t = line.trim();
1106        if t.starts_with("```") {
1107            fence = !fence;
1108            continue;
1109        }
1110        if fence || t.starts_with('|') || t.starts_with("![") || t.starts_with("<!--") {
1111            continue;
1112        }
1113        if t.is_empty() {
1114            if !cur.is_empty() && !cur.ends_with(':') {
1115                out.push(plain(&cur));
1116                cur.clear();
1117            }
1118        } else {
1119            cur.push(' ');
1120            cur.push_str(t);
1121        }
1122    }
1123    if !cur.is_empty() {
1124        out.push(plain(&cur));
1125    }
1126    out.retain(|p| !p.is_empty());
1127    out
1128}
1129
1130/// Leading sentences, enough to say what the change is (the printed line clips further).
1131fn first_sentences(s: &str) -> String {
1132    let mut out = String::new();
1133    for part in s.split_inclusive(". ") {
1134        out.push_str(part);
1135        if out.chars().count() >= CLAIM_CHARS {
1136            break;
1137        }
1138    }
1139    out.trim().to_string()
1140}
1141
1142fn words(s: &str, n: usize) -> String {
1143    s.split_whitespace().take(n).collect::<Vec<_>>().join(" ")
1144}
1145
1146fn clip(s: &str, n: usize) -> String {
1147    if s.chars().count() <= n {
1148        return s.to_string();
1149    }
1150    let cut: String = s.chars().take(n).collect();
1151    let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(a, _)| a);
1152    format!("{}…", cut.trim_end_matches([',', ';', ':']))
1153}
1154
1155fn slug(s: &str) -> String {
1156    let mut out = String::new();
1157    for c in s.chars() {
1158        if c.is_alphanumeric() || c == '.' {
1159            out.push(c.to_ascii_lowercase());
1160        } else if !out.ends_with('-') && !out.is_empty() {
1161            out.push('-');
1162        }
1163    }
1164    out.trim_end_matches('-').to_string()
1165}
1166
1167fn ver_str(v: &Ver) -> String {
1168    v.0.iter().map(u64::to_string).collect::<Vec<_>>().join(".")
1169}
1170
1171fn capitalise(s: &str) -> String {
1172    let mut c = s.chars();
1173    c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179    use std::path::PathBuf;
1180
1181    struct Fixture(PathBuf);
1182    impl Drop for Fixture {
1183        fn drop(&mut self) {
1184            let _ = std::fs::remove_dir_all(&self.0);
1185        }
1186    }
1187
1188    fn run(dir: &Path, args: &[&str]) {
1189        let ok = Command::new("git")
1190            .arg("-C")
1191            .arg(dir)
1192            .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
1193            .args(args)
1194            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1195            .env("GIT_CONFIG_NOSYSTEM", "1")
1196            .output()
1197            .unwrap()
1198            .status
1199            .success();
1200        assert!(ok, "git {args:?}");
1201    }
1202
1203    const SPEC1: &str = "# Core\n\n#### CORE-001: Token order\n\n**Priority:** MUST\n\nTokens in a place are consumed in FIFO order.\n\n---\n";
1204    const LOG1: &str = "# Changelog\n\n## Java 1.0.0 / Rust 1.0.0 — 2026-01-01\n\n### Added\n\n- **Executor.** Runs a net with FIFO token order.\n";
1205
1206    /// Commit 1: CORE-001 and release 1.0.0. Commit 2: CORE-002 (snapshot restore, since 2.0) and
1207    /// release 2.0.0 that adds it. Then an uncommitted CORE-003 in the working tree.
1208    fn fixture() -> (Fixture, String, String) {
1209        let dir = std::env::temp_dir().join(format!("borg-expert-{}-{:?}", std::process::id(), std::thread::current().id()));
1210        let _ = std::fs::remove_dir_all(&dir);
1211        std::fs::create_dir_all(dir.join("spec")).unwrap();
1212        let f = Fixture(dir.clone());
1213        run(&dir, &["init", "-q"]);
1214        std::fs::write(dir.join("spec/01-core.md"), SPEC1).unwrap();
1215        std::fs::write(dir.join("CHANGELOG.md"), LOG1).unwrap();
1216        std::fs::write(dir.join("README.md"), "# Demo\n\nA Petri net engine.\n\n## Install\n\nAdd the crate.\n").unwrap();
1217        run(&dir, &["add", "-A"]);
1218        run(&dir, &["commit", "-qm", "one"]);
1219        let c1 = git(&dir, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
1220        let spec2 = format!(
1221            "{SPEC1}\n#### CORE-002: Marking snapshot and restore\n\n**Priority:** MUST\n\nSince 2.0 a marking snapshot can be restored as the initial marking of a new run.\n"
1222        );
1223        std::fs::write(dir.join("spec/01-core.md"), spec2).unwrap();
1224        let log2 = LOG1.replace(
1225            "# Changelog\n\n",
1226            "# Changelog\n\n## Java 2.0.0 / Rust 2.1.0 — 2026-02-01\n\n### Added\n\n- **Snapshot restore ([CORE-002]).** A marking snapshot restores as the initial marking.\n\n## Rust 1.1.0 — 2026-01-15\n\n- Faster firing in Rust.\n\n",
1227        );
1228        std::fs::write(dir.join("CHANGELOG.md"), log2).unwrap();
1229        run(&dir, &["commit", "-qam", "two"]);
1230        let c2 = git(&dir, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
1231        let spec3 = std::fs::read_to_string(dir.join("spec/01-core.md")).unwrap() + "\n#### CORE-003: Snapshot export\n\nSnapshots export to JSON.\n";
1232        std::fs::write(dir.join("spec/01-core.md"), spec3).unwrap();
1233        (f, c1, c2)
1234    }
1235
1236    fn ids(a: &Answer) -> Vec<String> {
1237        a.claims.iter().map(|c| format!("{}#{}", c.source, c.id)).collect()
1238    }
1239
1240    #[test]
1241    fn a_claim_added_after_at_is_invisible() {
1242        let (f, c1, c2) = fixture();
1243        let q = "restore a marking snapshot as the initial marking";
1244        let early = ask(&f.0, &c1, None, q, None).unwrap();
1245        assert!(ids(&early).iter().all(|i| !i.contains("CORE-002") && !i.contains("2.0.0")), "{:?}", ids(&early));
1246        assert!(!early.render().to_lowercase().contains("snapshot"), "{}", early.render());
1247        let late = ask(&f.0, &c2, None, q, None).unwrap();
1248        assert!(ids(&late).contains(&"spec/01-core.md#CORE-002".to_string()), "{:?}", ids(&late));
1249        // The uncommitted working-tree requirement never shows, even at the latest commit.
1250        assert!(!late.render().contains("CORE-003"));
1251        assert!(ask(&f.0, "HEAD", None, "snapshot export JSON", None).unwrap().claims.iter().all(|c| c.id != "CORE-003"));
1252    }
1253
1254    #[test]
1255    fn newer_releases_surface_and_since_filters() {
1256        let (f, _, c2) = fixture();
1257        let a = ask(&f.0, &c2, Some("java 1.0.0"), "restore a marking snapshot", None).unwrap();
1258        let out = a.render();
1259        // CORE-002 says "since 2.0": not in 1.0.0, so dropped; the 2.0.0 release that adds it surfaces.
1260        assert!(!out.contains("#CORE-002]"), "{out}");
1261        assert!(out.contains("newer release Java 2.0.0 (2026-02-01): added Snapshot restore ([CORE-002])"), "{out}");
1262        assert!(out.starts_with(&format!("[{}@{} CHANGELOG.md#", f.0.file_name().unwrap().to_string_lossy(), a.short)), "{out}");
1263        // Pinned at 2.0.0 the requirement is in range and the release is not newer.
1264        let out = ask(&f.0, &c2, Some("java 2.0.0"), "restore a marking snapshot", None).unwrap().render();
1265        assert!(out.contains("#CORE-002] CORE-002 Marking snapshot and restore: Since 2.0"), "{out}");
1266        assert!(!out.contains("newer release"), "{out}");
1267        // Newer releases of the pinned line always come first, whatever the query; a Rust-only
1268        // release is not a newer Java release.
1269        let out = ask(&f.0, &c2, Some("java 1.0.0"), "FIFO token order", None).unwrap().render();
1270        let first = out.lines().next().unwrap();
1271        assert!(first.ends_with("1 newer release(s) than the pinned version: Java 2.0.0 (2026-02-01)"), "{out}");
1272        assert!(first.contains("CHANGELOG.md#java-2.0.0]"), "{out}");
1273        assert!(out.lines().count() <= MAX_CLAIMS);
1274        // A Rust pin reads its own line; at the newest Java nothing is newer.
1275        let out = ask(&f.0, &c2, Some("rust 2.0.0"), "FIFO token order", None).unwrap().render();
1276        assert!(out.lines().next().unwrap().ends_with("1 newer release(s) than the pinned version: Rust 2.1.0 (2026-02-01)"), "{out}");
1277        let out = ask(&f.0, &c2, Some("java 2.0.0"), "FIFO token order", None).unwrap().render();
1278        assert!(!out.contains("newer release"), "{out}");
1279    }
1280
1281    #[test]
1282    fn rust_only_release_is_newer_for_a_java_pin_by_position() {
1283        let (f, _, c2) = fixture();
1284        let claims = index(&f.0, &c2, Pin::parse("java 1.0.0").as_ref()).unwrap();
1285        let rust = claims.iter().find(|c| c.text.contains("Faster firing")).unwrap();
1286        assert_eq!(rust.kind, Kind::Release { newer: true });
1287        let claims = index(&f.0, &c2, Pin::parse("rust@2.1").as_ref()).unwrap();
1288        assert!(claims.iter().filter(|c| matches!(c.kind, Kind::Release { .. })).all(|c| c.kind == Kind::Release { newer: false }));
1289    }
1290
1291    /// A repo with one commit of `files`, in a directory of its own.
1292    fn repo_with(name: &str, files: &[(&str, &str)]) -> (Fixture, String) {
1293        let dir = std::env::temp_dir().join(format!("borg-expert-{name}-{}-{:?}", std::process::id(), std::thread::current().id()));
1294        let _ = std::fs::remove_dir_all(&dir);
1295        let f = Fixture(dir.clone());
1296        for (path, text) in files {
1297            let p = dir.join(path);
1298            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1299            std::fs::write(p, text).unwrap();
1300        }
1301        run(&dir, &["init", "-q"]);
1302        run(&dir, &["add", "-A"]);
1303        run(&dir, &["commit", "-qm", "one"]);
1304        let c = git(&dir, &["rev-parse", "HEAD"]).unwrap().trim().to_string();
1305        (f, c)
1306    }
1307
1308    const VER_SPEC: &str = "# Verification\n\n#### VER-001: Deadlock check\n\nThe verifier proves deadlock freedom.\n\n#### VER-024: Stubborn set reduction\n\nPartial order reduction of the state space with stubborn sets.\n\n#### VER-030: Uncited bound\n\nPlace bounds are reported.\n";
1309    const VER_LOG: &str = "# Changelog\n\n## Java 10.0.0 / Rust 11.0.0 — 2026-10-08\n\n- **Stubborn sets ([VER-024]).** Partial order reduction.\n- VER-001 is faster.\n\n## Java 9.0.0 / Rust 10.0.0 — 2026-10-05\n\n- **Deadlock check (VER-001).** First release of the verifier.\n";
1310    const VER_PROOF: &str = r#"{"theorems":[{"name":"stubborn_exact","module":"L/S.lean","specIds":[{"id":"VER-024","fragment":"untimed"}]},{"name":"deadlock_sound","module":"L/D.lean","specIds":[{"id":"VER-001","fragment":"untimed"}]}]}"#;
1311
1312    #[test]
1313    fn a_requirement_first_cited_in_a_newer_release_is_dropped() {
1314        let (f, c) = repo_with("late", &[("spec/07-verification.md", VER_SPEC), ("CHANGELOG.md", VER_LOG), ("lean/proof-coverage.json", VER_PROOF)]);
1315        let q = "stubborn set partial order reduction of the state space";
1316        let ids_of = |v: &str| {
1317            index(&f.0, &c, Pin::parse(v).as_ref()).unwrap().into_iter().filter(|c| !matches!(c.kind, Kind::Release { .. })).map(|c| c.id).collect::<Vec<_>>()
1318        };
1319        // Pinned at 9.0.0: VER-024 arrives in 10.0.0, though nothing in its text says "since".
1320        let at9 = ids_of("java 9.0.0");
1321        assert!(!at9.iter().any(|i| i == "VER-024" || i == "stubborn_exact"), "{at9:?}");
1322        // VER-001 was cited in 9.0.0 (and again later); VER-030 is never cited: both stay.
1323        assert!(["VER-001", "deadlock_sound", "VER-030"].iter().all(|i| at9.iter().any(|x| x == i)), "{at9:?}");
1324        let out = ask(&f.0, &c, Some("java 9.0.0"), q, None).unwrap().render();
1325        assert!(!out.contains("#VER-024]") && !out.contains("#stubborn_exact]"), "{out}");
1326        assert!(out.contains("newer release Java 10.0.0 (2026-10-08): Stubborn sets ([VER-024])"), "{out}");
1327        // The same line read through Rust, and a pin at 10.0.0, both see it.
1328        assert!(ids_of("rust 11.0.0").iter().any(|i| i == "VER-024"));
1329        let at10 = ids_of("java 10.0.0");
1330        assert!(at10.iter().any(|i| i == "VER-024") && at10.iter().any(|i| i == "stubborn_exact"), "{at10:?}");
1331        // Without a pin the repo at the commit is current: everything shows.
1332        assert!(ask(&f.0, &c, None, q, None).unwrap().render().contains("#VER-024]"));
1333    }
1334
1335    /// A flowlib-shaped repo: four language lines, a Java 10.0.0 above the consumer's 9.0.0.
1336    const E1_LOG: &str = "# Changelog\n\n\
1337## Java 10.0.0 / Rust 11.0.0 — 2026-10-08\n\n### Changed\n\n\
1338- **Breaking:** `PetriNet.verify()` reports in-flight tokens separately (VER-004).\n\
1339- Timer wheel granularity is configurable per executor.\n\
1340- DBM float drift is bounded for long-running timed nets.\n\n\
1341## TypeScript 9.0.0 — 2026-10-06\n\n- Order of places in the debug UI is stable.\n\n\
1342## Java 9.0.0 / Rust 10.0.0 — 2026-10-05\n\n\
1343- `bounded(k)` is refused on a place the net writes (VER-006).\n\
1344- Executor shutdown drains in-flight transitions.\n\n\
1345## Java 8.0.0 — 2026-09-01\n\n- First Java release of the verifier.\n";
1346    const E1_SPEC: &str = "# Core\n\n#### CORE-010: Token order\n\nTokens in a place are consumed in FIFO order by the executor.\n\n\
1347#### CORE-020: Timer transitions\n\nA timed transition fires within its interval; the executor schedules timers on a wheel.\n\n\
1348#### VER-006: Bounded places\n\n`bounded(k)` holds only for places the net never writes; `deadlockFree` is checked on the state class graph.\n";
1349    /// E1's plan shape: a long plan about porting a service, one sentence on flowlib.
1350    const E1_PLAN: &str = "## Plan: port the order service to the new runtime\n\n\
13511. Move the order state machine from the old scheduler into the new module, keeping the executor thread pool and the timer configuration.\n\
13522. Replace the hand-written retry loop with a bounded queue; tokens of work flow through the order places in FIFO order.\n\
13533. Model the order workflow as a net on flowlib 9.0.0 with `PetriNet.builder()`, and check it with `deadlockFree` before wiring it in.\n\
13544. Keep the debug UI, the metrics and the timer wheel; run the integration tests and update the docs.\n";
1355
1356    #[test]
1357    fn e1_a_plan_about_porting_on_java_9_surfaces_the_java_10_release() {
1358        let (f, c) = repo_with(
1359            "e1",
1360            &[("CHANGELOG.md", E1_LOG), ("spec/core.md", E1_SPEC), ("README.md", "# flowlib\n\nPetri nets for Java, TypeScript, Rust and Python.\n")],
1361        );
1362        let ix = Indexed::open(&f.0, &c, Some("java 9.0.0"), None).unwrap();
1363        let first = |a: &Answer| a.claims.first().map(|c| c.text.clone()).unwrap_or_default();
1364        // (a) Asked with the whole plan (what E1 did), the newer Java release still comes first.
1365        let whole = ix.answer(E1_PLAN);
1366        assert_eq!(first(&whole), "1 newer release(s) than the pinned version: Java 10.0.0 (2026-10-08)", "{}", whole.render());
1367        assert!(whole.render().lines().next().unwrap().contains("CHANGELOG.md#java-10.0.0]"), "{}", whole.render());
1368        // (b) The query is the plan's flowlib sentence plus the dependency's names, not the plan.
1369        let (sentences, query) = plan_query(E1_PLAN, &["flowlib", "org.flowlib"], &ix.api()).unwrap();
1370        assert_eq!(sentences.len(), 1, "{sentences:?}");
1371        assert!(sentences[0].contains("flowlib 9.0.0") && !query.contains("order service"), "{query}");
1372        let a = ix.answer(&query);
1373        assert_eq!(first(&a), "1 newer release(s) than the pinned version: Java 10.0.0 (2026-10-08)", "{}", a.render());
1374        assert!(a.claims.len() <= MAX_CLAIMS);
1375        // The focused query finds the breaking change to the API the plan uses.
1376        assert!(a.render().contains("PetriNet.verify()"), "{}", a.render());
1377    }
1378
1379    /// E1's changelog plus prose about everyday code, and the Java code that declares the API.
1380    const API_LOG: &str = "\n## Java 8.1.0 — 2026-09-10\n\n- `fireAsync` returns a `CompletableFuture`; markings moved from `HashMap` to `LinkedHashMap`; \
1381events carry `created_at` and `timeoutMs`; `ConcurrentHashMap` and `ExecutorService` are no longer exposed; `getState` is gone.\n";
1382    const API_JAVA: &str = "package org.flowlib;\n\nimport java.util.HashMap;\n\n/** Unlike a HashMap, a PetriNet is immutable. */\n\
1383public final class PetriNet {\n  private final HashMap<String, Long> timeoutMs = new HashMap<>();\n\
1384  public static Builder builder() { return new Builder(); }\n  public boolean deadlockFree() { return true; }\n\
1385  public CompletableFuture<Marking> fireAsync(Transition t) { return null; }\n  public String getState() { return \"\"; }\n\
1386  @Override public String toString() { return \"net\"; }\n}\n";
1387
1388    #[test]
1389    fn a_plan_mentions_a_dependency_by_name_or_api() {
1390        let log = format!("{E1_LOG}{API_LOG}");
1391        let (f, c) = repo_with("mention", &[("CHANGELOG.md", &log), ("spec/core.md", E1_SPEC), ("java/src/main/java/org/flowlib/PetriNet.java", API_JAVA)]);
1392        let api = Indexed::open(&f.0, &c, Some("java 9.0.0"), None).unwrap().api();
1393        assert!(api.types.contains("PetriNet") && api.members.contains("deadlockFree") && api.members.contains("fireAsync"), "{api:?}");
1394        // Named in the changelog but not declared by the repo, or a standard name: no API.
1395        for w in [
1396            "TypeScript",
1397            "Timer",
1398            "HashMap",
1399            "LinkedHashMap",
1400            "CompletableFuture",
1401            "ConcurrentHashMap",
1402            "ExecutorService",
1403            "created_at",
1404            "timeoutMs",
1405            "toString",
1406        ] {
1407            assert!(!api.types.contains(w) && !api.members.contains(w), "{w}: {api:?}");
1408        }
1409        let names = ["flowlib", "org.flowlib"];
1410        // Everyday Java in a project that depends on flowlib: never about flowlib.
1411        for plan in [
1412            "Replace the HashMap with a LinkedHashMap so iteration order is stable.",
1413            "Add a created_at column to the users table.",
1414            "Wrap the call in a CompletableFuture and submit it to the ExecutorService; pass timeoutMs through.",
1415            "Catch the InterruptedException and use a ConcurrentHashMap.",
1416        ] {
1417            assert_eq!(plan_query(plan, &names, &api), None, "{plan}");
1418        }
1419        // One member alone is too weak (the consumer's own getState); two, or a type, are the API.
1420        assert_eq!(plan_query("Call fireAsync on the order.", &names, &api), None);
1421        assert!(plan_query("Call fireAsync, then check deadlockFree.", &names, &api).is_some());
1422        // A consumer that declares the name itself means its own.
1423        let (own, _) = repo_with("consumer", &[("src/Net.java", "public class PetriNet { }\n")]);
1424        let plan = "Build the net with PetriNet.builder() first.";
1425        assert_eq!(plan_query(plan, &names, &api.clone().without_declared_in(&own.0.join("src"), plan)), None);
1426        let (other, _) = repo_with("consumer2", &[("src/App.java", "class App { PetriNet net = PetriNet.builder().build(); }\n")]);
1427        assert!(plan_query(plan, &names, &api.clone().without_declared_in(&other.0, plan)).is_some(), "using it is no declaring it");
1428        // By API only.
1429        let (s, _) = plan_query("Refactor the cache.\nBuild the net with PetriNet.builder() first.", &names, &api).unwrap();
1430        assert_eq!(s, ["Build the net with PetriNet.builder() first."]);
1431        // By coordinate; never by a word that merely contains the name.
1432        assert!(plan_query("Bump org.flowlib:flowlib to 10.", &names, &api).is_some());
1433        assert!(plan_query("Rename myflowlibx and the flowlibfication step.", &names, &api).is_none());
1434        assert!(plan_query("Fix the CSS of the login page. Then run the tests.", &names, &api).is_none());
1435    }
1436
1437    #[test]
1438    fn declarations_read_the_same_by_word_or_by_keyword() {
1439        let (f, c) =
1440            repo_with("decl", &[("java/PetriNet.java", API_JAVA), ("rs/lib.rs", "pub struct Marking;\npub fn fire_transition() {}\n// uses created_at\n")]);
1441        let few: BTreeSet<String> = ["PetriNet", "deadlockFree", "fire_transition", "Marking", "HashMap", "created_at"].map(String::from).into();
1442        let mut many = few.clone();
1443        many.extend((0..20).map(|i| format!("unusedName{i}")));
1444        for names in [&few, &many] {
1445            let api = declarations(&f.0, Some(&c), names);
1446            assert_eq!(api.types, ["Marking", "PetriNet"].map(String::from).into(), "{} names", names.len());
1447            assert_eq!(api.members, ["deadlockFree", "fire_transition"].map(String::from).into(), "{} names", names.len());
1448        }
1449        assert_eq!(declarations(Path::new("/nonexistent"), None, &few), Api::default(), "no repo, no API");
1450    }
1451
1452    #[test]
1453    fn unreleased_sections_are_never_newer_releases() {
1454        let log = "# Changelog\n\n## 2.0.0 (unreleased)\n\n- Modular composition of nets.\n\n## 1.8.5\n\n- Fix a timer.\n\n## 1.8.4\n\n- Fix a leak.\n";
1455        let (f, c) = repo_with("unrel", &[("CHANGELOG.md", log)]);
1456        let a = ask(&f.0, &c, Some("1.8.5"), "modular composition", None).unwrap();
1457        let out = a.render();
1458        assert!(out.contains(&format!("unreleased 2.0.0 (in progress at {}): Modular composition", a.short)), "{out}");
1459        assert!(!out.contains("newer release"), "{out}");
1460        // A labelled "— unreleased" heading, and an undated one above dated releases.
1461        let log = "# Changelog\n\n## Java 2.14.0\n\n- Draft work.\n\n## Java 2.13.0 — unreleased\n\n- `Builder.executor(ExecutorService)` renamed.\n\n## Java 2.12.0 — 2026-07-14\n\n- Executor tweaks.\n";
1462        let releases = parse_releases(log);
1463        assert_eq!(releases.iter().map(|r| r.unreleased).collect::<Vec<_>>(), [true, true, false]);
1464        assert!(releases[0].parts.iter().all(|(l, _)| l.as_deref() == Some("java")));
1465        let (f, c) = repo_with("unrel2", &[("CHANGELOG.md", log)]);
1466        let out = ask(&f.0, &c, Some("java 2.12.0"), "executor renamed", None).unwrap().render();
1467        assert!(out.contains("unreleased Java 2.13.0 (in progress at"), "{out}");
1468        assert!(!out.contains("newer release"), "{out}");
1469        // A `(unreleased)` suffix is no artifact line: an unlabelled pin still resolves.
1470        assert!(resolve_pin(&Pin::parse("1.0").unwrap(), &parse_releases("## 2.0.0 (unreleased)\n\n## 1.0.0 — 2026-01-01\n")).unwrap().label.is_none());
1471    }
1472
1473    #[test]
1474    fn an_unlabelled_pin_needs_a_single_line() {
1475        let one = parse_releases("## Java 2.0.0 — 2026-02-01\n\n## Java 1.0.0 — 2026-01-01\n");
1476        assert_eq!(resolve_pin(&Pin::parse("1.0.0").unwrap(), &one).unwrap().label.as_deref(), Some("java"));
1477        // flowlib at 739e671 / d2db2e8: labelled Rust and Python sections over unlabelled ones.
1478        let mixed = parse_releases("## Unreleased\n\n## 2.8.0 (Python) — 2026-06-01\n\n## 3.0.0 (Rust) / 2.7.0 (Python) — 2026-06-01\n\n## 2.6.0\n");
1479        let e = resolve_pin(&Pin::parse("2.6.0").unwrap(), &mixed).unwrap_err();
1480        assert!(e.contains("python, rust, (unlabelled)"), "{e}");
1481        assert_eq!(resolve_pin(&Pin::parse("python 2.6.0").unwrap(), &mixed).unwrap().label.as_deref(), Some("python"));
1482        // The fixture's Java + Rust changelog: an unlabelled pin is an error, not a guess.
1483        let (f, _, c2) = fixture();
1484        let e = ask(&f.0, &c2, Some("1.0.0"), "snapshot", None).unwrap_err();
1485        assert!(e.contains("java, rust"), "{e}");
1486        // The resolved line reaches in_range: a single-line Java changelog ignores "since rust 2.0".
1487        let spec = "# S\n\n#### CORE-009: Rust-only thing\n\nAvailable since rust 2.0 everywhere.\n";
1488        let (f, c) = repo_with("label", &[("spec/s.md", spec), ("CHANGELOG.md", "## Java 1.0.0 — 2026-01-01\n\n- First.\n")]);
1489        assert!(index(&f.0, &c, Pin::parse("1.0").as_ref()).unwrap().iter().any(|c| c.id == "CORE-009"));
1490    }
1491
1492    #[test]
1493    fn at_must_not_pass_the_ceiling() {
1494        let (f, c1, c2) = fixture();
1495        let q = "snapshot";
1496        assert!(ask(&f.0, &c1, None, q, Some(&c2)).is_ok());
1497        assert!(ask(&f.0, &c2[..10], None, q, Some(&c2)).is_ok());
1498        let e = ask(&f.0, &c2, None, q, Some(&c1)).unwrap_err();
1499        assert!(e.contains("not at or before the ceiling"), "{e}");
1500        // Symbolic refs are refused under a ceiling, even when they resolve below it.
1501        assert!(ask(&f.0, "HEAD~1", None, q, Some(&c2)).unwrap_err().contains("must be a commit hash"));
1502        assert!(ask(&f.0, "HEAD", None, q, None).is_ok());
1503    }
1504
1505    #[test]
1506    fn a_subdirectory_reads_the_whole_repo() {
1507        let (f, _, c2) = fixture();
1508        let a = ask(&f.0.join("spec"), &c2, None, "restore a marking snapshot as the initial marking", None).unwrap();
1509        assert_eq!(a.repo, f.0.file_name().unwrap().to_string_lossy());
1510        assert!(ids(&a).contains(&"spec/01-core.md#CORE-002".to_string()), "{:?}", ids(&a));
1511    }
1512
1513    #[test]
1514    fn git_ignores_the_callers_repository_variables() {
1515        let cmd = git_cmd(Path::new("."));
1516        let envs: Vec<_> = cmd.get_envs().collect();
1517        for v in GIT_ENV {
1518            assert!(envs.contains(&(std::ffi::OsStr::new(v), None)), "{v} not removed");
1519        }
1520        assert!(envs.contains(&(std::ffi::OsStr::new("GIT_NO_REPLACE_OBJECTS"), Some(std::ffi::OsStr::new("1")))));
1521    }
1522
1523    #[test]
1524    fn versions_and_pins() {
1525        assert!(Ver::parse("10").unwrap() == Ver::parse("10.0.0").unwrap());
1526        assert!(Ver::parse("v9.1").unwrap() < Ver::parse("10.0.0").unwrap());
1527        let p = Pin::parse("Java 9.0.0").unwrap();
1528        assert_eq!((p.label.as_deref(), p.ver.0.clone()), (Some("java"), vec![9, 0, 0]));
1529        assert_eq!(Pin::parse("v5").unwrap().label, None);
1530        assert!(Pin::parse("latest").is_none());
1531        let p = Pin::parse("2.0").unwrap();
1532        assert!(in_range("works since v2.0 and until 3.0", &p));
1533        assert!(!in_range("added in 2.1", &p));
1534        assert!(in_range("added in rust 2.1", &Pin::parse("java 2.0").unwrap()));
1535    }
1536
1537    #[test]
1538    fn docs_and_proofs_parse() {
1539        let d = doc_claims("README.md", "# Lib\n\nIntro text.\n\n```\n# not a heading\n```\n## Use it\n\nCall `run()`.\n");
1540        assert_eq!(d.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["lib", "use-it"]);
1541        assert_eq!(d[1].text, "Use it: Call `run()`.");
1542        let p = proof_claims(
1543            "lean/proof-coverage.json",
1544            r#"{"theorems":[{"name":"fifo_sound","module":"L/F.lean","specIds":[{"id":"CORE-001","fragment":"untimed"}]}]}"#,
1545        );
1546        assert_eq!(p[0].text, "Lean fifo_sound (L/F.lean) proves CORE-001 (untimed)");
1547    }
1548}