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