Skip to content

Commit 319e45e

Browse files
authored
Merge pull request #27 from thinkgrid-labs/feat/code-graph
fix(graph): refresh the code graph before every review
2 parents 2715cd4 + 70c8dc2 commit 319e45e

6 files changed

Lines changed: 98 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ else's branch. Both surfaces share one engine; neither replaces the other.
3030
enclosing definition, referenced definitions, and the file's tests. Everything
3131
stays inside a byte budget, so context does not grow with the repository.
3232
Bodies are read from the working tree rather than stored, so a snippet can
33-
never disagree with the file being reviewed. Adding a language is one entry in
33+
never disagree with the file being reviewed. The graph refreshes itself
34+
incrementally before every review (~0.1s on 647 unchanged files) and builds on
35+
first use, so it can never fall behind the code; `--no-index` opts out. Adding a language is one entry in
3436
a table; contributions welcome.
3537

3638
- **Cross-file review units.** When a symbol and code that calls it both change

apps/tui-cli/src/cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ pub struct Cli {
119119
#[arg(long)]
120120
pub no_cache: bool,
121121

122+
/// Do not refresh the code graph before reviewing. Faster, but findings are
123+
/// judged against whatever the graph last saw.
124+
#[arg(long)]
125+
pub no_index: bool,
126+
122127
/// Ignore `.diffmind/baseline.json` for this run
123128
#[arg(long)]
124129
pub no_baseline: bool,

apps/tui-cli/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub struct ReviewConfig {
3939
/// Same syntax as a rule's `files`: `*.ts`, `**/legacy/**`, or an exact path.
4040
#[serde(default)]
4141
pub ignore: Option<Vec<String>>,
42+
/// Refresh the code graph before each review. On by default — a stale graph
43+
/// reports wrong line ranges, not merely missing ones.
44+
pub auto_index: Option<bool>,
4245
}
4346

4447
#[derive(Debug, Deserialize, Default, Clone)]

apps/tui-cli/src/graph/store.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,50 @@ pub fn refresh(token: &str) -> bool {
488488
let _ = std::fs::remove_dir_all(&root);
489489
}
490490

491+
/// Why the graph is refreshed before every review rather than on demand.
492+
///
493+
/// A stale graph does not merely miss new code — its line ranges point at
494+
/// whatever now occupies those lines, and `source` reads the working tree,
495+
/// so the model is handed unrelated code labelled as the enclosing symbol.
496+
/// Confidently wrong context is worse than none.
497+
#[test]
498+
fn a_stale_graph_reports_the_wrong_lines_until_reindexed() {
499+
let root = project("stale");
500+
write(
501+
&root,
502+
"src/a.rs",
503+
"pub fn target() {\n let secret = 1;\n}\n",
504+
);
505+
let mut g = Graph::open(&root).unwrap();
506+
g.index(&root, &|_| {}).unwrap();
507+
508+
let before = g.definitions_of("target", None, 1).remove(0);
509+
assert!(before.source(&root, 100).unwrap().contains("let secret"));
510+
511+
// Someone adds imports at the top — utterly ordinary.
512+
std::thread::sleep(std::time::Duration::from_millis(20));
513+
write(
514+
&root,
515+
"src/a.rs",
516+
"// added\n// added\n// added\n// added\n// added\npub fn target() {\n let secret = 1;\n}\n",
517+
);
518+
519+
let stale = g.definitions_of("target", None, 1).remove(0);
520+
assert!(
521+
!stale.source(&root, 100).unwrap().contains("let secret"),
522+
"this is the failure mode being guarded against"
523+
);
524+
525+
// Re-indexing is what makes it right again, and is cheap enough to do
526+
// before every review.
527+
g.index(&root, &|_| {}).unwrap();
528+
let fresh = g.definitions_of("target", None, 1).remove(0);
529+
assert_eq!(fresh.start_line, 6);
530+
assert!(fresh.source(&root, 100).unwrap().contains("let secret"));
531+
532+
let _ = std::fs::remove_dir_all(&root);
533+
}
534+
491535
#[test]
492536
fn a_deleted_file_drops_out_of_the_graph() {
493537
let root = project("deleted");

apps/tui-cli/src/main.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ fn run() -> Result<i32> {
103103
// surfaces review exactly the same set of hunks.
104104
let (diff, prefilter) = apply_prefilter(&diff, &settings, &project_root)?;
105105

106+
// Refresh the graph before either surface reads it.
107+
if settings.auto_index {
108+
sync_graph(&project_root, settings.format.is_machine_readable());
109+
}
110+
106111
if prefilter.dropped_everything() {
107112
// Distinct from "no changes": there *were* changes, and none of them
108113
// were worth a reviewer's attention. Reporting zero findings without
@@ -725,6 +730,41 @@ fn read_head(path: &Path) -> std::io::Result<String> {
725730
/// a symbol lookup rather than a re-read of `symbols.json`. Returning a closure
726731
/// (instead of one context string for the whole diff) is what keeps each
727732
/// chunk's cache key independent of the other files in the diff.
733+
/// Bring the code graph up to date before reviewing.
734+
///
735+
/// Incremental and mtime-keyed: re-checking an unchanged 647-file repository
736+
/// costs about a tenth of a second, which is nothing beside one inference pass.
737+
///
738+
/// Doing it automatically matters more than the cost. A stale graph does not
739+
/// merely miss new code — it reports **wrong line ranges**, and `Def::source`
740+
/// then reads those lines out of the working tree and hands the model unrelated
741+
/// code labelled as the enclosing function. Confidently wrong context is worse
742+
/// than none, so the default is to never let the graph fall behind.
743+
///
744+
/// Never fatal: the graph is an optimisation, and a review must still run
745+
/// without it.
746+
fn sync_graph(project_root: &Path, quiet: bool) {
747+
let Ok(mut graph) = Graph::open(project_root) else {
748+
return;
749+
};
750+
// Only the first build is slow enough to be worth a spinner.
751+
let spinner = (graph.is_empty() && !quiet)
752+
.then(|| make_spinner("Building code graph (first run)...", false));
753+
754+
let progress = |n: usize| {
755+
if let Some(s) = &spinner {
756+
s.set_message(format!("Building code graph... {n} files"));
757+
}
758+
};
759+
if let Err(e) = graph.index(project_root, &progress) {
760+
eprintln!(" ! code graph not refreshed: {e}");
761+
}
762+
if let Some(s) = spinner {
763+
s.finish_and_clear();
764+
}
765+
runs::ensure_gitignore(project_root);
766+
}
767+
728768
/// Merge units the code graph says are two halves of one change. Without a
729769
/// graph this is the identity function, so behaviour is unchanged.
730770
pub fn unit_grouper(project_root: &Path) -> core_engine::analyzer::UnitGrouper {

apps/tui-cli/src/settings.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub struct Settings {
7171
pub debug: bool,
7272
/// Extra globs dropped by the pre-filter, from `.diffmind/config.toml`.
7373
pub ignore_globs: Vec<String>,
74+
/// Refresh the code graph before reviewing.
75+
pub auto_index: bool,
7476
}
7577

7678
pub fn resolve_settings(cli: &Cli, file: &FileConfig) -> Result<Settings> {
@@ -135,6 +137,7 @@ pub fn resolve_settings(cli: &Cli, file: &FileConfig) -> Result<Settings> {
135137
// *supposed* to differ, and replaying one would be a lie.
136138
use_cache: !cli.no_cache && resolve(None, r.cache, true) && temperature == 0.0,
137139
ignore_globs: r.ignore.clone().unwrap_or_default(),
140+
auto_index: !cli.no_index && resolve(None, r.auto_index, true),
138141
use_baseline: !cli.no_baseline,
139142
use_daemon: !cli.no_daemon,
140143
debug: cli.debug,

0 commit comments

Comments
 (0)