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