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//! # Naming a target
12//!
13//! A key is taken as it stands: `src/core/db.rs` is a file, and
14//! `src/core/db.rs#open` a symbol. Anything else is looked up as a bare symbol
15//! name, which is how a person refers to a function they have only heard of.
16//! Two symbols can share a name, and then the answer is the choice itself — the
17//! candidates and nothing else, so no caller mistakes one for the other.
18
19use crate::db::GraphDb;
20use crate::repograph::facts::{
21    commits_of, evidence_line, int_prop, label_of, list_prop, neighbors, neighbors_both,
22    owner_name, rank, score_of, str_prop, symbol_file,
23};
24use crate::repograph::map::SYNC_KEY;
25use crate::repograph::render::sanitize;
26use crate::Direction;
27use core_storage::fs::Fs;
28use core_storage::Value;
29use serde::Serialize;
30use std::path::Path;
31
32/// Source lines quoted at most, whichever end of a symbol they come from.
33pub const MAX_SOURCE_LINES: usize = 80;
34/// Callers and callees named, each.
35const MAX_CALLS: usize = 8;
36/// Files named on the import lines, each way.
37const MAX_IMPORTS: usize = 8;
38/// Co-change partners named.
39const MAX_PARTNERS: usize = 6;
40/// Commits named.
41const MAX_COMMITS: usize = 5;
42/// Notes and concepts named, each.
43const MAX_NOTES: usize = 3;
44
45/// What a `context` call was asked about, once resolved.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Target {
49    File {
50        path: String,
51    },
52    Symbol {
53        key: String,
54    },
55    /// No file or symbol answers to this name. Either nothing does, or several
56    /// symbols do — [`ContextReport::candidates`] tells the two apart.
57    Unknown {
58        target: String,
59    },
60}
61
62/// One file or symbol, from every side the graph can see it.
63#[derive(Debug, Clone, PartialEq, Serialize)]
64pub struct ContextReport {
65    pub target: Target,
66    /// Symbol keys sharing the bare name that was asked for. Non-empty only
67    /// when the name was ambiguous, and then nothing else is filled in.
68    pub candidates: Vec<String>,
69    pub signature: Option<String>,
70    pub doc: Option<String>,
71    /// `(first line, last line)` of a symbol, as extraction recorded them.
72    pub lines: Option<(u32, u32)>,
73    /// At most [`MAX_SOURCE_LINES`] lines from the working tree. `None` when
74    /// no repository path is known or the file cannot be read there.
75    pub source: Option<String>,
76    /// The file itself, or the file a symbol is defined in.
77    pub file: String,
78    /// The file's top author, by name.
79    pub owner: Option<String>,
80    /// `(symbol, the line it calls from)`, sorted by key.
81    pub callers: Vec<(String, u32)>,
82    /// `(symbol, the line it is called from)`, sorted by key.
83    pub callees: Vec<(String, u32)>,
84    pub importers: Vec<String>,
85    pub imports: Vec<String>,
86    /// `(file, co-change score)`, strongest first.
87    pub partners: Vec<(String, f64)>,
88    /// `(sha, timestamp, subject)`, newest first.
89    pub recent_commits: Vec<(String, i64, String)>,
90    /// `(note key, text)` for the notes written about it.
91    pub notes: Vec<(String, String)>,
92    /// `(concept key, name)` for the concepts learned from its file.
93    pub concepts: Vec<(String, String)>,
94}
95
96impl ContextReport {
97    /// An answer with the target named and nothing else known yet.
98    fn empty(target: Target) -> Self {
99        Self {
100            target,
101            candidates: Vec::new(),
102            signature: None,
103            doc: None,
104            lines: None,
105            source: None,
106            file: String::new(),
107            owner: None,
108            callers: Vec::new(),
109            callees: Vec::new(),
110            importers: Vec::new(),
111            imports: Vec::new(),
112            partners: Vec::new(),
113            recent_commits: Vec::new(),
114            notes: Vec::new(),
115            concepts: Vec::new(),
116        }
117    }
118}
119
120/// Everything known about `target`.
121///
122/// `repo` is the working tree the source is quoted from; without one the
123/// `GitSync` marker's `repo` is used, and a file that cannot be read there
124/// simply has no `source`. Everything else is read from the graph, so the
125/// answer is byte-identical for the same store and the same working tree.
126#[must_use]
127pub fn context<F: Fs>(db: &GraphDb<F>, repo: Option<&Path>, target: &str) -> ContextReport {
128    match resolve(db, target) {
129        Resolved::File(path) => {
130            let mut report = ContextReport::empty(Target::File {
131                path: sanitize(&path),
132            });
133            let symbols = neighbors(db, &path, "DEFINES", Direction::In);
134            report.callers = callers_of(db, &symbols, &path);
135            report.callees = callees_of(db, &symbols, &path);
136            report.source = read_source(db, repo, &path, None);
137            fill_file(db, &mut report, &path);
138            report.notes = notes_about(db, &[path]);
139            report
140        }
141        Resolved::Symbol(key) => {
142            let mut report = ContextReport::empty(Target::Symbol {
143                key: sanitize(&key),
144            });
145            // An undocumented symbol carries an empty `doc`, and an empty
146            // string is not a fact worth a line of the digest.
147            report.signature = text_prop(db, &key, "signature");
148            report.doc = text_prop(db, &key, "doc");
149            report.lines = symbol_lines(db, &key);
150            report.callers = callers_of(db, std::slice::from_ref(&key), "");
151            report.callees = callees_of(db, std::slice::from_ref(&key), "");
152            let file = symbol_file(db, &key).unwrap_or_default();
153            report.source = read_source(db, repo, &file, report.lines);
154            fill_file(db, &mut report, &file);
155            report.notes = notes_about(db, &[key, file]);
156            report
157        }
158        Resolved::Ambiguous(candidates) => {
159            let mut report = ContextReport::empty(Target::Unknown {
160                target: sanitize(target),
161            });
162            report.candidates = candidates;
163            report
164        }
165        Resolved::Unknown => ContextReport::empty(Target::Unknown {
166            target: sanitize(target),
167        }),
168    }
169}
170
171/// What a caller's target turned out to name.
172enum Resolved {
173    File(String),
174    Symbol(String),
175    /// Several symbols carry the bare name that was asked for.
176    Ambiguous(Vec<String>),
177    Unknown,
178}
179
180/// What the caller's `target` names: a key as it stands, or the symbols that
181/// carry it as a bare name.
182fn resolve<F: Fs>(db: &GraphDb<F>, target: &str) -> Resolved {
183    match label_of(db, target).as_deref() {
184        Some("File") => return Resolved::File(target.to_string()),
185        Some("Symbol") => return Resolved::Symbol(target.to_string()),
186        // A key of some other label — an author, a commit — is not something
187        // `context` describes, so it falls through to the name lookup and is
188        // reported unknown if nothing answers to it.
189        _ => {}
190    }
191    let mut named = named_symbols(db, target);
192    match named.len() {
193        0 => Resolved::Unknown,
194        1 => Resolved::Symbol(named.remove(0)),
195        _ => Resolved::Ambiguous(named),
196    }
197}
198
199/// Symbols whose bare `name` is `name`, by key.
200///
201/// A scan of the `Symbol` nodes, which is what an exact-match lookup on a
202/// field with no index costs — and this runs only when the caller's target is
203/// not a key, so a tool call that names one never pays for it.
204fn named_symbols<F: Fs>(db: &GraphDb<F>, name: &str) -> Vec<String> {
205    let mut out: Vec<String> = db
206        .nodes_with_label("Symbol")
207        .iter()
208        .filter(|n| matches!(n.prop("name"), Some(Value::Str(s)) if s == name))
209        .map(|n| sanitize(n.key()))
210        .collect();
211    out.sort();
212    out
213}
214
215/// A string prop that has something in it, sanitized. Blank is the same as
216/// absent: neither is worth a line.
217fn text_prop<F: Fs>(db: &GraphDb<F>, key: &str, field: &str) -> Option<String> {
218    str_prop(db, key, field)
219        .map(|s| sanitize(&s))
220        .filter(|s| !s.trim().is_empty())
221}
222
223/// The `(first, last)` line a symbol was extracted from.
224fn symbol_lines<F: Fs>(db: &GraphDb<F>, key: &str) -> Option<(u32, u32)> {
225    let start = u32::try_from(int_prop(db, key, "line_start")?).ok()?;
226    let end = u32::try_from(int_prop(db, key, "line_end")?).ok()?;
227    Some((start, end.max(start)))
228}
229
230/// Symbols that call any of `symbols`, with the line the call sits on.
231///
232/// `exclude_file` drops calls that stay inside one file: asking what calls a
233/// file means what calls it from outside. It is empty when the target is a
234/// symbol, where a caller in the same file is still a caller.
235fn callers_of<F: Fs>(
236    db: &GraphDb<F>,
237    symbols: &[String],
238    exclude_file: &str,
239) -> Vec<(String, u32)> {
240    let mut out: Vec<(String, u32)> = Vec::new();
241    for symbol in symbols {
242        for caller in neighbors(db, symbol, "CALLS", Direction::In) {
243            if !exclude_file.is_empty() && symbol_file(db, &caller).as_deref() == Some(exclude_file)
244            {
245                continue;
246            }
247            let line = evidence_line(&list_prop(db, &caller, "call_lines"), symbol).unwrap_or(0);
248            out.push((sanitize(&caller), line));
249        }
250    }
251    out.sort();
252    out.dedup();
253    out.truncate(MAX_CALLS);
254    out
255}
256
257/// Symbols any of `symbols` calls, with the line the call sits on.
258fn callees_of<F: Fs>(
259    db: &GraphDb<F>,
260    symbols: &[String],
261    exclude_file: &str,
262) -> Vec<(String, u32)> {
263    let mut out: Vec<(String, u32)> = Vec::new();
264    for symbol in symbols {
265        let lines = list_prop(db, symbol, "call_lines");
266        for callee in neighbors(db, symbol, "CALLS", Direction::Out) {
267            if !exclude_file.is_empty() && symbol_file(db, &callee).as_deref() == Some(exclude_file)
268            {
269                continue;
270            }
271            out.push((
272                sanitize(&callee),
273                evidence_line(&lines, &callee).unwrap_or(0),
274            ));
275        }
276    }
277    out.sort();
278    out.dedup();
279    out.truncate(MAX_CALLS);
280    out
281}
282
283/// The half of the report that is about the file, whichever kind of target
284/// led to it.
285fn fill_file<F: Fs>(db: &GraphDb<F>, report: &mut ContextReport, file: &str) {
286    report.file = sanitize(file);
287    if file.is_empty() {
288        return;
289    }
290    report.owner = owner_name(db, file).map(|n| sanitize(&n));
291    report.importers = neighbors(db, file, "IMPORTS", Direction::In)
292        .iter()
293        .take(MAX_IMPORTS)
294        .map(|k| sanitize(k))
295        .collect();
296    report.imports = neighbors(db, file, "IMPORTS", Direction::Out)
297        .iter()
298        .take(MAX_IMPORTS)
299        .map(|k| sanitize(k))
300        .collect();
301
302    let mut partners: Vec<(String, f64)> = neighbors_both(db, file, "CO_CHANGED")
303        .into_iter()
304        .map(|other| {
305            let score = score_of(db, "CO_CHANGED", file, &other).unwrap_or(0.0);
306            (sanitize(&other), score)
307        })
308        .collect();
309    rank(&mut partners);
310    partners.truncate(MAX_PARTNERS);
311    report.partners = partners;
312
313    report.recent_commits = commits_of(db, file)
314        .into_iter()
315        .take(MAX_COMMITS)
316        .map(|c| (sanitize(&c.sha), c.ts, sanitize(&c.subject)))
317        .collect();
318
319    report.concepts = neighbors(db, file, "DESCRIBED_IN", Direction::In)
320        .iter()
321        .take(MAX_NOTES)
322        .map(|key| {
323            let name = str_prop(db, key, "name").unwrap_or_else(|| key.clone());
324            (sanitize(key), sanitize(&name))
325        })
326        .collect();
327}
328
329/// The notes written about any of `keys`, by note key.
330fn notes_about<F: Fs>(db: &GraphDb<F>, keys: &[String]) -> Vec<(String, String)> {
331    let mut out: Vec<(String, String)> = Vec::new();
332    for key in keys.iter().filter(|k| !k.is_empty()) {
333        for note in neighbors(db, key, "ABOUT", Direction::In) {
334            let text = str_prop(db, &note, "text").unwrap_or_default();
335            out.push((sanitize(&note), sanitize(&text)));
336        }
337    }
338    out.sort();
339    out.dedup();
340    out.truncate(MAX_NOTES);
341    out
342}
343
344/// Where a `File` key sits in the working tree, or `None` when it does not sit
345/// inside it at all.
346///
347/// A `File` key is graph content: anything that can write a node chooses it,
348/// and nothing constrains it to a repository-relative path. Joined unchecked,
349/// an absolute key would replace the root outright and a `..` component would
350/// climb out of it, so `context` would quote whatever the process can read into
351/// an assistant's context. Three checks, in order:
352///
353/// - the key must be relative and made only of ordinary path segments, which
354///   rules out an absolute path, a Windows drive prefix and every `..`;
355/// - both the root and the joined path are canonicalised, which resolves every
356///   symlink on the way — including one planted inside the tree;
357/// - the result must still be under the canonical root.
358///
359/// Canonicalising also means a key naming a file that is not there fails here
360/// rather than at the read, which is the same answer: no source.
361fn inside_repo(root: &Path, file: &str) -> Option<std::path::PathBuf> {
362    let rel = Path::new(file);
363    if rel
364        .components()
365        .any(|c| !matches!(c, std::path::Component::Normal(_)))
366    {
367        return None;
368    }
369    let real_root = root.canonicalize().ok()?;
370    let real = real_root.join(rel).canonicalize().ok()?;
371    real.starts_with(&real_root).then_some(real)
372}
373
374/// The source of `file` from the working tree: a symbol's own lines, or the
375/// head of the file when no line range is given.
376///
377/// `repo` wins over the `GitSync` marker, so a caller working in a checkout
378/// elsewhere reads that one. Only a path [`inside_repo`] accepts is read.
379/// Anything that goes wrong — no repository, a key that leaves it, no such
380/// file, unreadable bytes — is simply no source: the rest of the report is
381/// still worth having.
382fn read_source<F: Fs>(
383    db: &GraphDb<F>,
384    repo: Option<&Path>,
385    file: &str,
386    lines: Option<(u32, u32)>,
387) -> Option<String> {
388    if file.is_empty() {
389        return None;
390    }
391    let root = match repo {
392        Some(p) => p.to_path_buf(),
393        None => std::path::PathBuf::from(str_prop(db, SYNC_KEY, "repo")?),
394    };
395    let text = std::fs::read_to_string(inside_repo(&root, file)?).ok()?;
396    let (first, last) = lines.unwrap_or((1, u32::MAX));
397    let skip = first.saturating_sub(1) as usize;
398    let take = (last.saturating_sub(first) as usize).saturating_add(1);
399    let excerpt: Vec<&str> = text
400        .lines()
401        .skip(skip)
402        .take(take.min(MAX_SOURCE_LINES))
403        .collect();
404    (!excerpt.is_empty()).then(|| excerpt.join("\n"))
405}