1use 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
40pub const MAX_CLAIMS: usize = 3;
43const CLAIM_CHARS: usize = 210;
46const INDEX_WORDS: usize = 160;
49pub 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#[derive(Debug, Clone)]
62pub struct Claim {
63 pub source: String,
65 pub id: String,
67 pub text: String,
69 index: String,
71 reqs: Vec<String>,
73 pub kind: Kind,
74 line_newer: bool,
76}
77
78impl Claim {
79 pub fn key(&self) -> String {
81 format!("{}#{}", self.source, self.id)
82 }
83}
84
85#[derive(Debug)]
87pub struct Answer {
88 pub repo: String,
89 pub short: String,
90 pub claims: Vec<Claim>,
91}
92
93impl Answer {
94 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
102pub 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
130pub 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#[derive(Debug)]
139pub struct Indexed {
140 pub repo: String,
141 pub short: String,
142 claims: Vec<Claim>,
143 pinned: bool,
144 top: PathBuf,
146 commit: String,
147}
148
149impl Indexed {
150 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 pub fn answer(&self, query: &str) -> Answer {
175 self.answer_except(query, &|_| false)
176 }
177
178 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 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#[derive(Debug, Clone, Default, PartialEq)]
205pub struct Api {
206 pub types: BTreeSet<String>,
207 pub members: BTreeSet<String>,
208}
209
210impl Api {
211 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 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
234const CODE: [&str; 13] = ["*.java", "*.kt", "*.scala", "*.ts", "*.tsx", "*.js", "*.mjs", "*.rs", "*.py", "*.go", "*.cs", "*.swift", "*.lean"];
236
237const 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
252fn 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 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());
291const NOT_API: [&str; 8] = ["JavaScript", "TypeScript", "GitHub", "GitLab", "macOS", "iOS", "README", "CHANGELOG"];
293
294const STD_API: &[&str] = &[
297 "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 "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 "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 "__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 "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
524fn 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
532pub 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
551fn 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
557fn 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 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
577fn 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
586pub 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
624const NEWER_MIN_SHARE: f64 = 0.25;
628
629fn 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
668fn 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
685const 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 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
707fn 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#[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#[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
764fn 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
776fn 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
790static 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
795fn 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());
831static 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 parts: Vec<(Option<String>, Ver)>,
838 title: String,
839 date: Option<String>,
840 unreleased: bool,
842 body: Vec<&'a str>,
843}
844
845fn 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 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
878fn 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
906fn 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 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 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
950fn 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
961fn late_reqs<'a>(releases: &'a [Release], newer: &[bool]) -> BTreeSet<&'a str> {
964 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
976fn changes(body: &[&str]) -> Vec<(String, String)> {
979 let mut out: Vec<(String, String)> = Vec::new();
980 let mut section = String::new();
981 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, §ion);
1001 section = h.trim_start_matches('#').trim().to_string();
1002 } else if line.starts_with("- ") || line.starts_with("* ") {
1003 push(&mut cur, &mut out, §ion);
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, §ion);
1008 }
1009 } else if let Some(c) = cur.as_mut().filter(|c| indented && c.1 || !blank) {
1010 c.0.push(' ');
1012 c.0.push_str(line.trim());
1013 } else {
1014 push(&mut cur, &mut out, §ion);
1015 cur = Some((line.trim().to_string(), false));
1016 }
1017 blank = line.trim().is_empty();
1018 }
1019 push(&mut cur, &mut out, §ion);
1020 out
1021}
1022
1023fn 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
1051fn 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
1090static LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[([^\]]*)\]\([^)]*\)").unwrap());
1093static HTML: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
1094
1095fn 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
1102fn 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
1134fn 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 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 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 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 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 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 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 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 let at9 = ids_of("java 9.0.0");
1325 assert!(!at9.iter().any(|i| i == "VER-024" || i == "stubborn_exact"), "{at9:?}");
1326 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 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 assert!(ask(&f.0, &c, None, q, None).unwrap().render().contains("#VER-024]"));
1337 }
1338
1339 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 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 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 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 assert!(a.render().contains("PetriNet.verify()"), "{}", a.render());
1381 }
1382
1383 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 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 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 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 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 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 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 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 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 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 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 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 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}