Skip to content

Commit e145de3

Browse files
committed
feat(repo): add incremental indexing and scoped smells
1 parent 69f5089 commit e145de3

8 files changed

Lines changed: 983 additions & 68 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ grapha flow trace sendGift --direction reverse
7777

7878
# Code smell detection
7979
grapha repo smells --module Room
80+
grapha repo smells --file Modules/Room/Sources/Room/View/RoomPage+Layout.swift
81+
grapha repo smells --symbol RoomPageCenterContentView
8082

8183
# Module metrics — symbol counts, coupling ratios
8284
grapha repo modules
@@ -114,7 +116,7 @@ Add to `.mcp.json`:
114116
| `get_file_symbols` | All declarations in a file, by source position |
115117
| `batch_context` | Context for up to 20 symbols in one call |
116118
| `analyze_complexity` | Structural metrics + severity rating for any type |
117-
| `detect_smells` | Graph-wide code smell scan (god types, fan-out, nesting, etc.) |
119+
| `detect_smells` | Code smell scan scoped to the repo, a module, a file, or a symbol |
118120
| `get_module_summary` | Per-module metrics with cross-module coupling ratio |
119121
| `get_file_map` | File/symbol map organized by module and directory |
120122
| `reload` | Hot-reload graph from disk without restarting the server |
@@ -144,7 +146,7 @@ grapha flow entries # list auto-detected entry points
144146
### Repository
145147

146148
```bash
147-
grapha repo smells [--module M] # code smell detection
149+
grapha repo smells [--module M | --file PATH | --symbol QUERY]
148150
grapha repo modules # per-module metrics
149151
grapha repo map [--module M] # file/symbol overview
150152
grapha repo changes [scope] # git diff → affected symbols
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Repo Smells Scope Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Add `--file` and `--symbol` scope options to `grapha repo smells` so users can run local smell analysis for one file or one symbol neighborhood.
6+
7+
**Architecture:** Extend the CLI to accept mutually exclusive smell scopes, add scoped subgraph construction in the smell query layer, and keep existing module/full-repo behavior unchanged. Reuse existing file and symbol resolution utilities so scope selection follows the same matching rules as other repo queries.
8+
9+
**Tech Stack:** Rust, clap, existing `grapha` query/resolution modules, integration tests with `assert_cmd`
10+
11+
---
12+
13+
### Task 1: Add failing integration coverage for new smell scopes
14+
15+
**Files:**
16+
- Modify: `grapha/tests/integration.rs`
17+
18+
- [ ] Add an integration test for `grapha repo smells --file <file>` on a small fixture project.
19+
- [ ] Run the targeted integration test and verify it fails because `--file` is not supported yet.
20+
- [ ] Add an integration test for `grapha repo smells --symbol <symbol>` on a small fixture project.
21+
- [ ] Run the targeted integration test and verify it fails because `--symbol` is not supported yet.
22+
23+
### Task 2: Add CLI flags and scope-aware repo smell dispatch
24+
25+
**Files:**
26+
- Modify: `grapha/src/main.rs`
27+
28+
- [ ] Add `--file` and `--symbol` flags to `RepoCommands::Smells`, keeping them mutually exclusive with `--module`.
29+
- [ ] Update smell command handling to resolve the selected scope and call the scoped query function.
30+
- [ ] Run the targeted integration tests and verify they now fail in the query layer instead of argument parsing.
31+
32+
### Task 3: Implement scope-relative smell analysis
33+
34+
**Files:**
35+
- Modify: `grapha/src/query/smells.rs`
36+
- Modify: `grapha/src/query.rs`
37+
38+
- [ ] Add a scope type for smell analysis that supports full graph, module filter, file scope, and symbol neighborhood scope.
39+
- [ ] Reuse existing file matching and symbol resolution helpers to collect the scoped node set.
40+
- [ ] Build a reduced graph from the scoped nodes plus connecting edges needed for local smell metrics.
41+
- [ ] Run unit tests for the smell query layer and verify the new scope behavior passes.
42+
43+
### Task 4: Verify end-to-end behavior and performance
44+
45+
**Files:**
46+
- Modify: `grapha/tests/integration.rs` if assertions need tightening
47+
48+
- [ ] Run `cargo test -p grapha`.
49+
- [ ] Run `cargo build --release -p grapha`.
50+
- [ ] Run `target/release/grapha repo smells --file <...>` and `--symbol <...>` against a real indexed project and confirm output shape and timing.

grapha/src/cache.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
44
use std::time::{SystemTime, UNIX_EPOCH};
55

66
use anyhow::Context;
7+
use grapha_core::extract::ExtractionResult;
78
use grapha_core::graph::Graph;
89
use serde::{Deserialize, Serialize};
910

@@ -61,19 +62,85 @@ impl GraphCache {
6162

6263
const QUERY_CACHE_FILENAME: &str = "query_cache.bin";
6364
const MAX_QUERY_CACHE_ENTRIES: usize = 64;
65+
const EXTRACTION_CACHE_FILENAME: &str = "extraction_cache.bin";
6466

6567
#[derive(Serialize, Deserialize)]
6668
struct QueryCacheEntry {
6769
db_mtime_secs: u64,
6870
output: String,
6971
}
7072

73+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74+
pub struct FileStamp {
75+
pub len: u64,
76+
pub modified_secs: u64,
77+
pub modified_nanos: u32,
78+
}
79+
80+
impl FileStamp {
81+
pub fn from_path(path: &Path) -> Option<Self> {
82+
let metadata = fs::metadata(path).ok()?;
83+
let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
84+
Some(Self {
85+
len: metadata.len(),
86+
modified_secs: modified.as_secs(),
87+
modified_nanos: modified.subsec_nanos(),
88+
})
89+
}
90+
}
91+
92+
#[derive(Debug, Clone, Serialize, Deserialize)]
93+
pub struct ExtractionCacheEntry {
94+
pub stamp: FileStamp,
95+
pub module_name: Option<String>,
96+
pub result: ExtractionResult,
97+
}
98+
99+
pub struct ExtractionCache {
100+
cache_path: PathBuf,
101+
}
102+
71103
/// Cache for serialized query output, keyed by a string and invalidated when
72104
/// the SQLite database changes.
73105
pub struct QueryCache {
74106
cache_path: PathBuf,
75107
}
76108

109+
impl ExtractionCache {
110+
pub fn new(store_dir: &Path) -> Self {
111+
Self {
112+
cache_path: store_dir.join(EXTRACTION_CACHE_FILENAME),
113+
}
114+
}
115+
116+
pub fn load_entries(&self) -> anyhow::Result<HashMap<String, ExtractionCacheEntry>> {
117+
let Ok(contents) = fs::read_to_string(&self.cache_path) else {
118+
return Ok(HashMap::new());
119+
};
120+
serde_json::from_str(&contents).with_context(|| {
121+
format!(
122+
"deserialising extraction cache {}",
123+
self.cache_path.display()
124+
)
125+
})
126+
}
127+
128+
pub fn save_entries(
129+
&self,
130+
entries: &HashMap<String, ExtractionCacheEntry>,
131+
) -> anyhow::Result<()> {
132+
if let Some(parent) = self.cache_path.parent() {
133+
fs::create_dir_all(parent)?;
134+
}
135+
let contents = serde_json::to_string(entries).with_context(|| {
136+
format!("serialising extraction cache {}", self.cache_path.display())
137+
})?;
138+
fs::write(&self.cache_path, contents)
139+
.with_context(|| format!("writing extraction cache {}", self.cache_path.display()))?;
140+
Ok(())
141+
}
142+
}
143+
77144
fn mtime_secs(path: &Path) -> Option<u64> {
78145
fs::metadata(path)
79146
.ok()?
@@ -159,11 +226,37 @@ impl GraphCache {
159226

160227
#[cfg(test)]
161228
mod tests {
229+
use std::collections::HashMap;
230+
use std::path::PathBuf;
162231
use std::thread;
163232
use std::time::Duration;
164233

234+
use grapha_core::graph::{Node, NodeKind, Span, Visibility};
235+
165236
use super::*;
166237

238+
fn sample_extraction_result(file: &str) -> ExtractionResult {
239+
let mut result = ExtractionResult::new();
240+
result.nodes.push(Node {
241+
id: format!("{file}::main"),
242+
kind: NodeKind::Function,
243+
name: "main".to_string(),
244+
file: PathBuf::from(file),
245+
span: Span {
246+
start: [0, 0],
247+
end: [1, 0],
248+
},
249+
visibility: Visibility::Private,
250+
metadata: HashMap::new(),
251+
role: None,
252+
signature: None,
253+
doc_comment: None,
254+
module: Some("sample".to_string()),
255+
snippet: Some("fn main() {}".to_string()),
256+
});
257+
result
258+
}
259+
167260
// ── cache_is_fresh ────────────────────────────────────────────────────────
168261

169262
#[test]
@@ -283,4 +376,44 @@ mod tests {
283376
assert_eq!(qc.get("key_a", &db_path).as_deref(), Some("output_a"));
284377
assert_eq!(qc.get("key_b", &db_path).as_deref(), Some("output_b"));
285378
}
379+
380+
#[test]
381+
fn file_stamp_changes_when_file_changes() {
382+
let dir = tempfile::tempdir().unwrap();
383+
let file = dir.path().join("main.rs");
384+
fs::write(&file, "fn main() {}\n").unwrap();
385+
let first = FileStamp::from_path(&file).unwrap();
386+
387+
thread::sleep(Duration::from_millis(10));
388+
fs::write(&file, "fn main() { println!(\"hi\"); }\n").unwrap();
389+
let second = FileStamp::from_path(&file).unwrap();
390+
391+
assert_ne!(first, second);
392+
}
393+
394+
#[test]
395+
fn extraction_cache_round_trips_entries() {
396+
let dir = tempfile::tempdir().unwrap();
397+
let cache = ExtractionCache::new(dir.path());
398+
let file = dir.path().join("main.rs");
399+
fs::write(&file, "fn main() {}\n").unwrap();
400+
401+
let mut entries = HashMap::new();
402+
entries.insert(
403+
"main.rs".to_string(),
404+
ExtractionCacheEntry {
405+
stamp: FileStamp::from_path(&file).unwrap(),
406+
module_name: Some("sample".to_string()),
407+
result: sample_extraction_result("main.rs"),
408+
},
409+
);
410+
411+
cache.save_entries(&entries).unwrap();
412+
let loaded = cache.load_entries().unwrap();
413+
414+
assert_eq!(loaded.len(), 1);
415+
let entry = loaded.get("main.rs").unwrap();
416+
assert_eq!(entry.module_name.as_deref(), Some("sample"));
417+
assert_eq!(entry.result.nodes[0].name, "main");
418+
}
286419
}

0 commit comments

Comments
 (0)