core_api/repograph/context.rs
1//! `context` — everything the graph knows about one file or symbol.
2//!
3//! The question an assistant asks before editing something it has not read:
4//! what is this, what does it look like, who owns it, what calls it, what does
5//! it call, what imports it, what changes with it, what has been said about it.
6//! One node lookup answers all of it, because `ingest-git` already wrote the
7//! edges; the only thing read from outside the graph is the source itself,
8//! quoted from the working tree so the excerpt is what is on disk now rather
9//! than what was committed.
10//!
11//! # Pointers, not bodies
12//!
13//! The body is the expensive half of the answer and the half a caller can
14//! fetch itself, so it is off unless asked for: a default answer carries the
15//! line range, the signature and the graph's facts, and
16//! [`ContextOptions::source`] adds the lines. A reader who only needed to know
17//! where something lives pays for a pointer.
18//!
19//! # Naming a target
20//!
21//! A key is taken as it stands: `src/core/db.rs` is a file, and
22//! `src/core/db.rs#open` a symbol. Anything else is looked up as a bare symbol
23//! name, which is how a person refers to a function they have only heard of.
24//! Two symbols can share a name, and then the answer is the choice itself — the
25//! candidates and nothing else, so no caller mistakes one for the other.
26//!
27//! # Callers are call sites
28//!
29//! "What calls this" is answered from the callers' `call_lines`, not from the
30//! `CALLS` edges alone, and grouped by the file the calls sit in. An edge is
31//! written once however many times a call is written, so counting edges
32//! reported a symbol called twelve times from six functions as six call sites —
33//! and a person changing a signature has twelve lines to visit, not six.
34
35use crate::db::GraphDb;
36use crate::repograph::facts::{
37 commits_of, evidence_line, evidence_lines, int_prop, label_of, list_prop, neighbors,
38 neighbors_both, owner_name, rank, score_of, str_prop, symbol_file,
39};
40use crate::repograph::map::SYNC_KEY;
41use crate::repograph::render::sanitize;
42use crate::Direction;
43use core_storage::fs::Fs;
44use core_storage::Value;
45use serde::Serialize;
46use std::collections::{BTreeMap, BTreeSet};
47use std::path::Path;
48
49/// Source lines quoted at most, whichever end of a symbol they come from.
50pub const MAX_SOURCE_LINES: usize = 80;
51/// Callees named.
52const MAX_CALLS: usize = 8;
53/// Files named on the `callers` line. Generous, because an incomplete answer
54/// here is the one that misleads: a caller left out of a blast radius is a
55/// caller nobody goes and looks at.
56const MAX_CALLER_FILES: usize = 12;
57/// Call sites named per calling file, past which the count stands in for the
58/// lines.
59///
60/// Eight is where the line stops being read and starts being skimmed: what a
61/// reader needs from a file with fifty call sites is the file and the order of
62/// magnitude, not fifty numbers. Naming the file is what makes the answer
63/// complete; naming every line in it is what made the digest twice as wide as
64/// the one it replaced.
65const MAX_SITES_PER_FILE: usize = 8;
66/// Files named on the import lines, each way.
67const MAX_IMPORTS: usize = 8;
68/// Co-change partners named.
69const MAX_PARTNERS: usize = 6;
70/// Commits named.
71const MAX_COMMITS: usize = 5;
72/// Notes and concepts named, each.
73const MAX_NOTES: usize = 3;
74
75/// What a `context` call was asked about, once resolved.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum Target {
79 File {
80 path: String,
81 },
82 Symbol {
83 key: String,
84 },
85 /// No file or symbol answers to this name. Either nothing does, or several
86 /// symbols do — [`ContextReport::candidates`] tells the two apart.
87 Unknown {
88 target: String,
89 },
90}
91
92/// Everywhere one file calls the target.
93///
94/// A `CALLS` edge says *which symbol* calls another, one edge however many
95/// times the call is written. That answers "who calls this" and cannot answer
96/// "where is it called": a function called twelve times from six others looked
97/// like six call sites. The lines come from the caller's `call_lines`, which
98/// records every site, so this is the whole list.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct CallSites {
101 /// The file the calling symbols are defined in.
102 pub file: String,
103 /// The calling symbols, by key, sorted.
104 pub symbols: Vec<String>,
105 /// Lines of `file` a call sits on, ascending, at most
106 /// [`MAX_SITES_PER_FILE`] of them. `sites` is the true total either way, so
107 /// a reader can always tell a short list from a truncated one.
108 pub lines: Vec<u32>,
109 /// Call sites in `file`, including any the `lines` cap left out.
110 pub sites: usize,
111}
112
113/// One file or symbol, from every side the graph can see it.
114#[derive(Debug, Clone, PartialEq, Serialize)]
115pub struct ContextReport {
116 pub target: Target,
117 /// Symbol keys sharing the bare name that was asked for. Non-empty only
118 /// when the name was ambiguous, and then nothing else is filled in.
119 pub candidates: Vec<String>,
120 pub signature: Option<String>,
121 pub doc: Option<String>,
122 /// `(first line, last line)` of a symbol, as extraction recorded them.
123 pub lines: Option<(u32, u32)>,
124 /// At most [`MAX_SOURCE_LINES`] lines from the working tree. `None` when
125 /// the caller did not ask for a body ([`ContextOptions::source`]), when no
126 /// repository path is known, or when the file cannot be read there.
127 pub source: Option<String>,
128 /// The file itself, or the file a symbol is defined in.
129 pub file: String,
130 /// The file's top author, by name.
131 pub owner: Option<String>,
132 /// Every call site into the target, grouped by the file the calls sit in,
133 /// most call sites first. See [`CallSites`].
134 pub callers: Vec<CallSites>,
135 /// How many caller files [`MAX_CALLER_FILES`] left out of `callers`.
136 pub callers_not_shown: usize,
137 /// `(symbol, the line it is called from)`, sorted by key.
138 pub callees: Vec<(String, u32)>,
139 pub importers: Vec<String>,
140 pub imports: Vec<String>,
141 /// `(file, co-change score)`, strongest first.
142 pub partners: Vec<(String, f64)>,
143 /// `(sha, timestamp, subject)`, newest first.
144 pub recent_commits: Vec<(String, i64, String)>,
145 /// `(note key, text)` for the notes written about it.
146 pub notes: Vec<(String, String)>,
147 /// `(concept key, name)` for the concepts learned from its file.
148 pub concepts: Vec<(String, String)>,
149}
150
151impl ContextReport {
152 /// An answer with the target named and nothing else known yet.
153 fn empty(target: Target) -> Self {
154 Self {
155 target,
156 candidates: Vec::new(),
157 signature: None,
158 doc: None,
159 lines: None,
160 source: None,
161 file: String::new(),
162 owner: None,
163 callers: Vec::new(),
164 callers_not_shown: 0,
165 callees: Vec::new(),
166 importers: Vec::new(),
167 imports: Vec::new(),
168 partners: Vec::new(),
169 recent_commits: Vec::new(),
170 notes: Vec::new(),
171 concepts: Vec::new(),
172 }
173 }
174}
175
176/// What a `context` call reads beyond the graph.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub struct ContextOptions {
179 /// Quote the target's body from the working tree.
180 ///
181 /// Off by default, because a body is the expensive half of the answer and
182 /// rarely the half that decides anything: a caller reading a digest wants
183 /// to know where the thing is, what it looks like and what touches it, and
184 /// can open the file itself once it has the pointer. On, the report carries
185 /// [`ContextReport::source`] as before.
186 pub source: bool,
187}
188
189/// Everything known about `target`, with the body quoted.
190///
191/// The pointer-only form is [`context_with`]; this is it with
192/// [`ContextOptions::source`] set, kept as its own function because every
193/// caller that wants the body wants nothing else configured.
194#[must_use]
195pub fn context<F: Fs>(db: &GraphDb<F>, repo: Option<&Path>, target: &str) -> ContextReport {
196 context_with(db, repo, target, &ContextOptions { source: true })
197}
198
199/// Everything known about `target`.
200///
201/// `repo` is the working tree the source is quoted from; without one the
202/// `GitSync` marker's `repo` is used, and a file that cannot be read there
203/// simply has no `source`. Everything else is read from the graph, so the
204/// answer is byte-identical for the same store and the same working tree.
205///
206/// With `opts.source` clear the working tree is not read at all — not read and
207/// discarded — so the answer is the graph's and nothing else's.
208#[must_use]
209pub fn context_with<F: Fs>(
210 db: &GraphDb<F>,
211 repo: Option<&Path>,
212 target: &str,
213 opts: &ContextOptions,
214) -> ContextReport {
215 match resolve(db, target) {
216 Resolved::File(path) => {
217 let mut report = ContextReport::empty(Target::File {
218 path: sanitize(&path),
219 });
220 let symbols = neighbors(db, &path, "DEFINES", Direction::In);
221 (report.callers, report.callers_not_shown) = callers_of(db, &symbols, &path);
222 report.callees = callees_of(db, &symbols, &path);
223 if opts.source {
224 report.source = read_source(db, repo, &path, None);
225 }
226 fill_file(db, &mut report, &path);
227 report.notes = notes_about(db, &[path]);
228 report
229 }
230 Resolved::Symbol(key) => {
231 let mut report = ContextReport::empty(Target::Symbol {
232 key: sanitize(&key),
233 });
234 // An undocumented symbol carries an empty `doc`, and an empty
235 // string is not a fact worth a line of the digest.
236 report.signature = text_prop(db, &key, "signature");
237 report.doc = text_prop(db, &key, "doc");
238 report.lines = symbol_lines(db, &key);
239 (report.callers, report.callers_not_shown) =
240 callers_of(db, std::slice::from_ref(&key), "");
241 report.callees = callees_of(db, std::slice::from_ref(&key), "");
242 let file = symbol_file(db, &key).unwrap_or_default();
243 if opts.source {
244 report.source = read_source(db, repo, &file, report.lines);
245 }
246 fill_file(db, &mut report, &file);
247 report.notes = notes_about(db, &[key, file]);
248 report
249 }
250 Resolved::Ambiguous(candidates) => {
251 let mut report = ContextReport::empty(Target::Unknown {
252 target: sanitize(target),
253 });
254 report.candidates = candidates;
255 report
256 }
257 Resolved::Unknown => ContextReport::empty(Target::Unknown {
258 target: sanitize(target),
259 }),
260 }
261}
262
263/// What a caller's target turned out to name.
264enum Resolved {
265 File(String),
266 Symbol(String),
267 /// Several symbols carry the bare name that was asked for.
268 Ambiguous(Vec<String>),
269 Unknown,
270}
271
272/// What the caller's `target` names: a key as it stands, or the symbols that
273/// carry it as a bare name.
274fn resolve<F: Fs>(db: &GraphDb<F>, target: &str) -> Resolved {
275 match label_of(db, target).as_deref() {
276 Some("File") => return Resolved::File(target.to_string()),
277 Some("Symbol") => return Resolved::Symbol(target.to_string()),
278 // A key of some other label — an author, a commit — is not something
279 // `context` describes, so it falls through to the name lookup and is
280 // reported unknown if nothing answers to it.
281 _ => {}
282 }
283 let mut named = named_symbols(db, target);
284 match named.len() {
285 0 => Resolved::Unknown,
286 1 => Resolved::Symbol(named.remove(0)),
287 _ => Resolved::Ambiguous(named),
288 }
289}
290
291/// Symbols whose bare `name` is `name`, by key.
292///
293/// A scan of the `Symbol` nodes, which is what an exact-match lookup on a
294/// field with no index costs — and this runs only when the caller's target is
295/// not a key, so a tool call that names one never pays for it.
296///
297/// Public because "does the graph know this bare name?" is asked outside
298/// `context` too — the `PreToolUse` grep redirect asks it of a search pattern —
299/// and both answers must come from the same lookup, or a redirect could point
300/// at an `explore` that then reports nothing.
301pub fn named_symbols<F: Fs>(db: &GraphDb<F>, name: &str) -> Vec<String> {
302 let mut out: Vec<String> = db
303 .nodes_with_label("Symbol")
304 .iter()
305 .filter(|n| matches!(n.prop("name"), Some(Value::Str(s)) if s == name))
306 .map(|n| sanitize(n.key()))
307 .collect();
308 out.sort();
309 out
310}
311
312/// A string prop that has something in it, sanitized. Blank is the same as
313/// absent: neither is worth a line.
314fn text_prop<F: Fs>(db: &GraphDb<F>, key: &str, field: &str) -> Option<String> {
315 str_prop(db, key, field)
316 .map(|s| sanitize(&s))
317 .filter(|s| !s.trim().is_empty())
318}
319
320/// The `(first, last)` line a symbol was extracted from.
321fn symbol_lines<F: Fs>(db: &GraphDb<F>, key: &str) -> Option<(u32, u32)> {
322 let start = u32::try_from(int_prop(db, key, "line_start")?).ok()?;
323 let end = u32::try_from(int_prop(db, key, "line_end")?).ok()?;
324 Some((start, end.max(start)))
325}
326
327/// Every call site into any of `symbols`, grouped by the file it sits in.
328///
329/// `exclude_file` drops calls that stay inside one file: asking what calls a
330/// file means what calls it from outside. It is empty when the target is a
331/// symbol, where a caller in the same file is still a caller.
332///
333/// Returns the groups and how many files were cut by [`MAX_CALLER_FILES`].
334/// Files come back with the most call sites first, ties on the path, so a cut
335/// only ever loses the least-involved callers — and the count says so rather
336/// than leaving the answer looking complete.
337fn callers_of<F: Fs>(
338 db: &GraphDb<F>,
339 symbols: &[String],
340 exclude_file: &str,
341) -> (Vec<CallSites>, usize) {
342 let mut by_file: BTreeMap<String, (BTreeSet<String>, BTreeSet<u32>)> = BTreeMap::new();
343 for symbol in symbols {
344 for caller in neighbors(db, symbol, "CALLS", Direction::In) {
345 let file = symbol_file(db, &caller).unwrap_or_default();
346 if !exclude_file.is_empty() && file == exclude_file {
347 continue;
348 }
349 let lines = evidence_lines(&list_prop(db, &caller, "call_lines"), symbol);
350 let slot = by_file.entry(sanitize(&file)).or_default();
351 slot.0.insert(sanitize(&caller));
352 // A call the evidence list has no line for still happened, so the
353 // caller is named; line 0 is how every digest here says "unknown".
354 slot.1
355 .extend(if lines.is_empty() { vec![0] } else { lines });
356 }
357 }
358 let total = by_file.len();
359 let mut out: Vec<CallSites> = by_file
360 .into_iter()
361 .map(|(file, (symbols, lines))| CallSites {
362 file,
363 symbols: symbols.into_iter().collect(),
364 sites: lines.len(),
365 lines: lines.into_iter().take(MAX_SITES_PER_FILE).collect(),
366 })
367 .collect();
368 out.sort_by(|a, b| b.sites.cmp(&a.sites).then(a.file.cmp(&b.file)));
369 out.truncate(MAX_CALLER_FILES);
370 let cut = total - out.len();
371 (out, cut)
372}
373
374/// Symbols any of `symbols` calls, with the line the call sits on.
375fn callees_of<F: Fs>(
376 db: &GraphDb<F>,
377 symbols: &[String],
378 exclude_file: &str,
379) -> Vec<(String, u32)> {
380 let mut out: Vec<(String, u32)> = Vec::new();
381 for symbol in symbols {
382 let lines = list_prop(db, symbol, "call_lines");
383 for callee in neighbors(db, symbol, "CALLS", Direction::Out) {
384 if !exclude_file.is_empty() && symbol_file(db, &callee).as_deref() == Some(exclude_file)
385 {
386 continue;
387 }
388 out.push((
389 sanitize(&callee),
390 evidence_line(&lines, &callee).unwrap_or(0),
391 ));
392 }
393 }
394 out.sort();
395 out.dedup();
396 out.truncate(MAX_CALLS);
397 out
398}
399
400/// The half of the report that is about the file, whichever kind of target
401/// led to it.
402fn fill_file<F: Fs>(db: &GraphDb<F>, report: &mut ContextReport, file: &str) {
403 report.file = sanitize(file);
404 if file.is_empty() {
405 return;
406 }
407 report.owner = owner_name(db, file).map(|n| sanitize(&n));
408 report.importers = neighbors(db, file, "IMPORTS", Direction::In)
409 .iter()
410 .take(MAX_IMPORTS)
411 .map(|k| sanitize(k))
412 .collect();
413 report.imports = neighbors(db, file, "IMPORTS", Direction::Out)
414 .iter()
415 .take(MAX_IMPORTS)
416 .map(|k| sanitize(k))
417 .collect();
418
419 let mut partners: Vec<(String, f64)> = neighbors_both(db, file, "CO_CHANGED")
420 .into_iter()
421 .map(|other| {
422 let score = score_of(db, "CO_CHANGED", file, &other).unwrap_or(0.0);
423 (sanitize(&other), score)
424 })
425 .collect();
426 rank(&mut partners);
427 partners.truncate(MAX_PARTNERS);
428 report.partners = partners;
429
430 report.recent_commits = commits_of(db, file)
431 .into_iter()
432 .take(MAX_COMMITS)
433 .map(|c| (sanitize(&c.sha), c.ts, sanitize(&c.subject)))
434 .collect();
435
436 report.concepts = neighbors(db, file, "DESCRIBED_IN", Direction::In)
437 .iter()
438 .take(MAX_NOTES)
439 .map(|key| {
440 let name = str_prop(db, key, "name").unwrap_or_else(|| key.clone());
441 (sanitize(key), sanitize(&name))
442 })
443 .collect();
444}
445
446/// The notes written about any of `keys`, by note key.
447fn notes_about<F: Fs>(db: &GraphDb<F>, keys: &[String]) -> Vec<(String, String)> {
448 let mut out: Vec<(String, String)> = Vec::new();
449 for key in keys.iter().filter(|k| !k.is_empty()) {
450 for note in neighbors(db, key, "ABOUT", Direction::In) {
451 let text = str_prop(db, ¬e, "text").unwrap_or_default();
452 out.push((sanitize(¬e), sanitize(&text)));
453 }
454 }
455 out.sort();
456 out.dedup();
457 out.truncate(MAX_NOTES);
458 out
459}
460
461/// Where a `File` key sits in the working tree, or `None` when it does not sit
462/// inside it at all.
463///
464/// A `File` key is graph content: anything that can write a node chooses it,
465/// and nothing constrains it to a repository-relative path. Joined unchecked,
466/// an absolute key would replace the root outright and a `..` component would
467/// climb out of it, so `context` would quote whatever the process can read into
468/// an assistant's context. Three checks, in order:
469///
470/// - the key must be relative and made only of ordinary path segments, which
471/// rules out an absolute path, a Windows drive prefix and every `..`;
472/// - both the root and the joined path are canonicalised, which resolves every
473/// symlink on the way — including one planted inside the tree;
474/// - the result must still be under the canonical root.
475///
476/// Canonicalising also means a key naming a file that is not there fails here
477/// rather than at the read, which is the same answer: no source.
478fn inside_repo(root: &Path, file: &str) -> Option<std::path::PathBuf> {
479 let rel = Path::new(file);
480 if rel
481 .components()
482 .any(|c| !matches!(c, std::path::Component::Normal(_)))
483 {
484 return None;
485 }
486 let real_root = root.canonicalize().ok()?;
487 let real = real_root.join(rel).canonicalize().ok()?;
488 real.starts_with(&real_root).then_some(real)
489}
490
491/// The source of `file` from the working tree: a symbol's own lines, or the
492/// head of the file when no line range is given.
493///
494/// `repo` wins over the `GitSync` marker, so a caller working in a checkout
495/// elsewhere reads that one. Only a path [`inside_repo`] accepts is read.
496/// Anything that goes wrong — no repository, a key that leaves it, no such
497/// file, unreadable bytes — is simply no source: the rest of the report is
498/// still worth having.
499fn read_source<F: Fs>(
500 db: &GraphDb<F>,
501 repo: Option<&Path>,
502 file: &str,
503 lines: Option<(u32, u32)>,
504) -> Option<String> {
505 if file.is_empty() {
506 return None;
507 }
508 let root = match repo {
509 Some(p) => p.to_path_buf(),
510 None => std::path::PathBuf::from(str_prop(db, SYNC_KEY, "repo")?),
511 };
512 let text = std::fs::read_to_string(inside_repo(&root, file)?).ok()?;
513 let (first, last) = lines.unwrap_or((1, u32::MAX));
514 let skip = first.saturating_sub(1) as usize;
515 let take = (last.saturating_sub(first) as usize).saturating_add(1);
516 let excerpt: Vec<&str> = text
517 .lines()
518 .skip(skip)
519 .take(take.min(MAX_SOURCE_LINES))
520 .collect();
521 (!excerpt.is_empty()).then(|| excerpt.join("\n"))
522}