Skip to main content

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, &note, "text").unwrap_or_default();
452            out.push((sanitize(&note), 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}