Skip to content

Commit 7904d88

Browse files
committed
fix(fingerprint): address review findings on corpus mode
Four issues raised by CodeRabbit on #70, three of them real defects: 1. O(n^2) building the shape -> groups map. `Vec::contains` was a linear membership scan repeated per indexed entry, and it degraded exactly on the path this feature exists for — one shape carried by many groups. CLAUDE.md forbids O(n^2) algorithms outright. Now a BTreeSet, which also removes the separate sort since it iterates in order. Verified to produce byte-identical output to the previous implementation on a 955-document corpus, so this is purely a complexity fix. 2. An unreadable subdirectory aborted the whole scan. `read_dir(...)?` propagated out of collect_corpus_files, so one permission-denied directory failed the entire command after thousands of files had already been walked — inconsistent with the per-file error model build_index already used. Directory and entry failures are now collected into the same `errors` list as parse failures, and the walk continues. 3. Entry-level failures were silently dropped. `let Ok(entry) = ... else continue` discarded unreadable entries with no counter and no diagnostic, so files_scanned could undercount without saying so. Each now produces an error entry. 4. No coverage for --format text in corpus mode, and the text output labelled cluster size as "document(s)" when `size` counts groups. The label directly contradicted the design rationale — a cluster of 3 packages can span dozens of files. Corrected to "group(s)" and pinned by a test that also asserts the old wording is absent. collect_corpus_files now returns a `Walk` struct rather than a tuple, since it carries three things. Tests: +2 integration tests (text output reports counts and labels groups; an unreadable directory is reported and non-fatal, unix-gated).
1 parent ac4e432 commit 7904d88

3 files changed

Lines changed: 179 additions & 42 deletions

File tree

vajra-cli/src/corpus.rs

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//! - **clusters**: documents linked transitively through *any* shared shape,
1414
//! because related documents typically share several files rather than one.
1515
16-
use std::collections::BTreeMap;
16+
use std::collections::{BTreeMap, BTreeSet};
1717
use std::path::{Path, PathBuf};
1818

1919
use anyhow::{Context, Result};
@@ -120,45 +120,85 @@ pub struct CorpusError {
120120
pub error: String,
121121
}
122122

123+
/// What a corpus walk found.
124+
pub struct Walk {
125+
/// Files the selector accepted, sorted.
126+
pub selected: Vec<PathBuf>,
127+
/// Every file encountered, whether selected or not.
128+
pub scanned: usize,
129+
/// Directories and entries that could not be read — reported rather than
130+
/// dropped silently or made fatal.
131+
pub errors: Vec<CorpusError>,
132+
}
133+
123134
/// Recursively collect files under `dir`, partitioned by `accept`.
124135
///
125136
/// Unlike `batch` and `cluster`, this walk **recurses**: a corpus is normally a
126137
/// tree of extracted packages or checkouts, so the interesting files are nested.
127138
///
139+
/// An unreadable subdirectory or entry is recorded in [`Walk::errors`] and the
140+
/// walk continues. Aborting a scan of thousands of files because one directory
141+
/// denied permission is the wrong trade, and would be inconsistent with the
142+
/// per-file error model `build_index` already uses.
143+
///
128144
/// # Errors
129145
///
130-
/// Returns an error if `dir` is not a directory or cannot be read.
131-
pub fn collect_corpus_files(
132-
dir: &Path,
133-
accept: &dyn Fn(&Path) -> bool,
134-
) -> Result<(Vec<PathBuf>, usize)> {
146+
/// Returns an error only if `dir` itself is not a directory.
147+
pub fn collect_corpus_files(dir: &Path, accept: &dyn Fn(&Path) -> bool) -> Result<Walk> {
135148
if !dir.is_dir() {
136149
anyhow::bail!("{} is not a directory", dir.display());
137150
}
138-
let mut selected = Vec::new();
139-
let mut scanned = 0usize;
151+
let mut walk = Walk {
152+
selected: Vec::new(),
153+
scanned: 0,
154+
errors: Vec::new(),
155+
};
140156
let mut stack = vec![dir.to_path_buf()];
141157

142158
while let Some(current) = stack.pop() {
143-
let entries = std::fs::read_dir(&current)
144-
.with_context(|| format!("failed to read directory {}", current.display()))?;
159+
let entries = match std::fs::read_dir(&current) {
160+
Ok(entries) => entries,
161+
Err(e) => {
162+
walk.errors.push(CorpusError {
163+
file: current.display().to_string(),
164+
error: format!("failed to read directory: {e}"),
165+
});
166+
continue;
167+
}
168+
};
145169
let mut dirs = Vec::new();
146170
for entry in entries {
147-
let Ok(entry) = entry else { continue };
171+
let entry = match entry {
172+
Ok(entry) => entry,
173+
Err(e) => {
174+
walk.errors.push(CorpusError {
175+
file: current.display().to_string(),
176+
error: format!("failed to read directory entry: {e}"),
177+
});
178+
continue;
179+
}
180+
};
148181
let path = entry.path();
149182
// Do not follow symlinks: a cycle would hang the walk.
150-
let Ok(meta) = entry.file_type() else {
151-
continue;
183+
let meta = match entry.file_type() {
184+
Ok(meta) => meta,
185+
Err(e) => {
186+
walk.errors.push(CorpusError {
187+
file: path.display().to_string(),
188+
error: format!("failed to stat: {e}"),
189+
});
190+
continue;
191+
}
152192
};
153193
if meta.is_symlink() {
154194
continue;
155195
}
156196
if meta.is_dir() {
157197
dirs.push(path);
158198
} else if meta.is_file() {
159-
scanned += 1;
199+
walk.scanned += 1;
160200
if accept(&path) {
161-
selected.push(path);
201+
walk.selected.push(path);
162202
}
163203
}
164204
}
@@ -168,8 +208,9 @@ pub fn collect_corpus_files(
168208
stack.extend(dirs);
169209
}
170210

171-
selected.sort();
172-
Ok((selected, scanned))
211+
walk.selected.sort();
212+
walk.errors.sort_by(|a, b| a.file.cmp(&b.file));
213+
Ok(walk)
173214
}
174215

175216
/// Build the shape-reuse index over `files`.
@@ -181,13 +222,13 @@ pub fn collect_corpus_files(
181222
/// indexing it.
182223
pub fn build_index(
183224
root: &Path,
184-
files: &[PathBuf],
185-
scanned: usize,
225+
walk: &Walk,
186226
min_nodes: u64,
187227
group_depth: usize,
188228
load: &(dyn Fn(&Path) -> Result<Document> + Send + Sync),
189229
shape_of: &(dyn Fn(&Document) -> Result<String> + Send + Sync),
190230
) -> CorpusIndex {
231+
let files = &walk.selected;
191232
let outcomes: Vec<(PathBuf, Result<Option<Indexed>>)> = files
192233
.par_iter()
193234
.map(|path| {
@@ -211,7 +252,16 @@ pub fn build_index(
211252
.collect();
212253

213254
let mut indexed = Vec::new();
214-
let mut errors = Vec::new();
255+
// Walk-level failures (unreadable directories) belong in the same list as
256+
// parse failures: both are files the caller asked about and did not get.
257+
let mut errors: Vec<CorpusError> = walk
258+
.errors
259+
.iter()
260+
.map(|e| CorpusError {
261+
file: e.file.clone(),
262+
error: e.error.clone(),
263+
})
264+
.collect();
215265
let mut suppressed = 0usize;
216266
for (path, outcome) in outcomes {
217267
match outcome {
@@ -253,21 +303,24 @@ pub fn build_index(
253303

254304
// Clustering links *groups*, not files: shape -> the distinct groups
255305
// carrying it. A shape confined to one group links nothing.
256-
let mut shape_to_groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
306+
//
307+
// A BTreeSet rather than Vec + `contains`: the whole point of this feature
308+
// is shapes carried by many groups, so a linear membership scan per entry
309+
// would degrade toward O(n^2) exactly on the path that matters.
310+
let mut shape_to_groups: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
257311
for entry in &indexed {
258-
let slot = shape_to_groups.entry(&entry.shape).or_default();
259-
if !slot.contains(&entry.group.as_str()) {
260-
slot.push(&entry.group);
261-
}
312+
shape_to_groups
313+
.entry(&entry.shape)
314+
.or_default()
315+
.insert(&entry.group);
262316
}
263317
let cross_group: Vec<(&str, Vec<&str>, u64)> = shape_to_groups
264318
.iter()
265319
.filter(|(_, groups)| groups.len() > 1)
266320
.map(|(shape, groups)| {
267321
let nodes = by_shape.get(*shape).map_or(0, |(n, _)| *n);
268-
let mut g = groups.clone();
269-
g.sort_unstable();
270-
(*shape, g, nodes)
322+
// BTreeSet iterates in sorted order already.
323+
(*shape, groups.iter().copied().collect(), nodes)
271324
})
272325
.collect();
273326

@@ -280,10 +333,10 @@ pub fn build_index(
280333
let clusters = build_clusters(&cross_group);
281334

282335
CorpusIndex {
283-
files_scanned: scanned,
336+
files_scanned: walk.scanned,
284337
documents_indexed: indexed.len(),
285338
groups_indexed,
286-
skipped: scanned.saturating_sub(files.len()),
339+
skipped: walk.scanned.saturating_sub(files.len()),
287340
suppressed,
288341
distinct_shapes,
289342
shapes_in_multiple_documents,
@@ -473,10 +526,11 @@ mod tests {
473526
std::fs::write(dir.path().join("readme.txt"), "x")?;
474527

475528
let json_only = |p: &Path| p.extension().is_some_and(|e| e == "json");
476-
let (files, scanned) = collect_corpus_files(dir.path(), &json_only)?;
477-
assert_eq!(files.len(), 3, "walk must recurse");
478-
assert_eq!(scanned, 4, "scanned counts every file seen");
479-
assert!(files.windows(2).all(|w| w[0] <= w[1]), "sorted");
529+
let walk = collect_corpus_files(dir.path(), &json_only)?;
530+
assert_eq!(walk.selected.len(), 3, "walk must recurse");
531+
assert_eq!(walk.scanned, 4, "scanned counts every file seen");
532+
assert!(walk.errors.is_empty());
533+
assert!(walk.selected.windows(2).all(|w| w[0] <= w[1]), "sorted");
480534
Ok(())
481535
}
482536

vajra-cli/src/main.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,23 +1651,27 @@ fn cmd_fingerprint_corpus(
16511651
cli: &Cli,
16521652
) -> Result<()> {
16531653
let dir = Path::new(directory);
1654-
let (files, scanned) = corpus::collect_corpus_files(dir, &|p| is_selectable_file(p, cli))?;
1654+
let walk = corpus::collect_corpus_files(dir, &|p| is_selectable_file(p, cli))?;
16551655

1656-
if files.is_empty() {
1656+
if walk.selected.is_empty() {
16571657
anyhow::bail!(
1658-
"no analysable files found under {directory} ({scanned} file(s) scanned). \
1659-
Use --input-format source to index source files."
1658+
"no analysable files found under {directory} ({} file(s) scanned). \
1659+
Use --input-format source to index source files.",
1660+
walk.scanned
16601661
);
16611662
}
16621663

16631664
if !cli.quiet {
1664-
eprintln!("Indexing {} of {scanned} file(s)...", files.len());
1665+
eprintln!(
1666+
"Indexing {} of {} file(s)...",
1667+
walk.selected.len(),
1668+
walk.scanned
1669+
);
16651670
}
16661671

16671672
let index = corpus::build_index(
16681673
dir,
1669-
&files,
1670-
scanned,
1674+
&walk,
16711675
min_nodes,
16721676
group_depth,
16731677
&|p| load_document_path(p, cli),
@@ -1721,7 +1725,7 @@ fn cmd_fingerprint_corpus(
17211725
} else {
17221726
for c in &index.clusters {
17231727
println!(
1724-
" {} document(s), {} shared shape(s), min nodes {}",
1728+
" {} group(s), {} shared shape(s), min nodes {}",
17251729
c.size, c.shared_shapes, c.min_node_count
17261730
);
17271731
for m in &c.members {

vajra-cli/tests/fingerprint_corpus.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,85 @@ fn index_is_deterministic() -> Result<()> {
296296
Ok(())
297297
}
298298

299+
/// Text mode must state the counts and label clusters as *groups*, not
300+
/// documents — `size` counts clustering units, and a cluster of 3 packages can
301+
/// span dozens of files.
302+
#[test]
303+
fn text_output_reports_counts_and_labels_groups() -> Result<()> {
304+
let dir = three_package_corpus()?;
305+
let out = Command::new(vajra_bin())
306+
.arg("fingerprint")
307+
.arg(as_str(dir.path())?)
308+
.arg("--corpus")
309+
.args(JS)
310+
.arg("--quiet")
311+
.output()?;
312+
313+
assert!(
314+
out.status.success(),
315+
"text mode failed: {}",
316+
String::from_utf8_lossy(&out.stderr)
317+
);
318+
let stdout = String::from_utf8_lossy(&out.stdout);
319+
for expected in [
320+
"Corpus Shape Index",
321+
"Files scanned:",
322+
"Documents indexed:",
323+
"Distinct shapes:",
324+
"Reuse Groups",
325+
"Clusters",
326+
] {
327+
assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}");
328+
}
329+
assert!(
330+
stdout.contains("group(s)"),
331+
"clusters must be labelled as groups, not documents:\n{stdout}"
332+
);
333+
assert!(
334+
!stdout.contains("document(s), "),
335+
"cluster size must not be called documents:\n{stdout}"
336+
);
337+
Ok(())
338+
}
339+
340+
/// An unreadable subdirectory must be reported and the walk must continue —
341+
/// failing a scan of thousands of files because one directory denied permission
342+
/// would be the wrong trade.
343+
#[cfg(unix)]
344+
#[test]
345+
fn unreadable_directory_is_reported_not_fatal() -> Result<()> {
346+
use std::os::unix::fs::PermissionsExt;
347+
348+
let dir = TempDir::new()?;
349+
write(dir.path(), "pkg-a/lib/index.js", TEMPLATE_A)?;
350+
write(dir.path(), "pkg-b/lib/index.js", TEMPLATE_B)?;
351+
let locked = dir.path().join("locked");
352+
std::fs::create_dir(&locked)?;
353+
write(dir.path(), "locked/hidden.js", OTHER)?;
354+
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))?;
355+
356+
let result = corpus(dir.path(), JS);
357+
358+
// Restore permissions before any assertion so the TempDir can clean up.
359+
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
360+
let json = result?;
361+
362+
assert_eq!(
363+
json["documents_indexed"], 2,
364+
"readable packages still indexed"
365+
);
366+
let errors = json["errors"]
367+
.as_array()
368+
.ok_or_else(|| anyhow!("errors missing"))?;
369+
assert!(
370+
errors
371+
.iter()
372+
.any(|e| e["file"].as_str().is_some_and(|f| f.contains("locked"))),
373+
"the unreadable directory must be reported: {errors:?}"
374+
);
375+
Ok(())
376+
}
377+
299378
#[test]
300379
fn parse_failures_are_reported_not_dropped() -> Result<()> {
301380
let dir = TempDir::new()?;

0 commit comments

Comments
 (0)