diff --git a/crates/vera-cli/src/cli.rs b/crates/vera-cli/src/cli.rs index d655c085..c0caf6e7 100644 --- a/crates/vera-cli/src/cli.rs +++ b/crates/vera-cli/src/cli.rs @@ -553,8 +553,10 @@ pub enum Commands { #[command(flatten)] filters: crate::helpers::SearchFilterArgs, - /// Maximum number of results (default: 20). - #[arg(long, short = 'n')] + /// Maximum number of results (default: 20). A trailing bare `-n` is + /// accepted and treated as the default, since line numbers are + /// always printed. + #[arg(long, short = 'n', num_args = 0..=1, default_missing_value = "20")] limit: Option, /// Case-insensitive matching. diff --git a/crates/vera-cli/src/commands/agent.rs b/crates/vera-cli/src/commands/agent.rs index 786f0ac9..3e3e3b45 100644 --- a/crates/vera-cli/src/commands/agent.rs +++ b/crates/vera-cli/src/commands/agent.rs @@ -1181,25 +1181,29 @@ fn sync_with_options( const VERA_SNIPPET_BEGIN_MARKER: &str = ""; const VERA_SNIPPET_END_MARKER: &str = ""; const AGENTS_MD_SNIPPET_HEADING: &str = "## Code Search"; -const AGENTS_MD_SNIPPET_INTRO: &str = "Before reading files to answer \"where is X\", \"how does Y work\", or \"find Z\", search first with Vera."; +const AGENTS_MD_SNIPPET_INTRO: &str = "Your first search in this repository goes through Vera, not grep, rg, or file reads: run `vera overview` when you have not searched this repository yet, then `vera search \"\"`. Fall back to grep or rg only after a Vera call has missed."; const AGENTS_MD_SNIPPET: &str = r#"## Code Search -Before reading files to answer "where is X", "how does Y work", or "find Z", search first with Vera. - -- `vera search "query"` for semantic code search. Describe behavior: "JWT validation", not "auth". If one phrasing misses, try 2-3 varied queries or add `--intent "goal"`. -- `vera grep "pattern"` for exact text or regex in indexed files -- `vera references ` for callers and `vera references --callees` for callees; add `--receiver ` when several definitions share a name -- `vera structural definitions `, `vera structural env `, `vera structural routes`, or `vera structural impls ` for common structural tasks -- `vera explain-path path/to/file` to explain why a file is or is not indexed -- `vera overview` for a project summary (languages, entry points, hotspots). Add `--changed`, `--since `, or `--base ` to scope it to modified files. -- `vera stats --json` for index health, including tree-sitter error, parse-failure, and Tier 0 fallback counts -- `vera search --deep "query"` for RAG-fusion query expansion + merged ranking -- Narrow `vera search` or `vera grep` with `--lang`, `--path`, `--type`, or `--scope docs` -- `vera watch .` to auto-update the index, or `vera update .` after edits (`vera index .` if `.vera/` is missing) -- For detailed usage, query patterns, and troubleshooting, read the Vera skill file installed by `vera agent install` +Your first search in this repository goes through Vera, not grep, rg, or file reads: run `vera overview` when you have not searched this repository yet, then `vera search ""`. Fall back to grep or rg only after a Vera call has missed. + +Route by question type: + +- How or where something works: `vera search "request teardown ordering"`. Describe behavior ("JWT validation"), not nouns ("auth"). If a phrasing misses, add `--intent ""` or use `--deep`. +- The question names a function, class, or config key: `vera structural definitions ` for the definition, `vera references ` for callers (`--callees` for callees). One call returns ranked definitions or call sites, not raw matching lines. +- Exact text or regex: `vera grep "pattern"`. Line numbers are always shown; cap results with `--limit `. +- Enumerate every X (signals, routes, env reads, implementations): `vera structural routes`, `vera structural env `, `vera structural impls `, or `vera grep "X" --lang `. + +Working with results: + +- Hits are `path:start-end kind:name` plus the code. Cite from the hit; open a file only for lines the hit did not include. +- If the top hit is a usage site, re-run with the symbol name or `vera structural definitions ` instead of trying more phrasings. +- `--path` is relative to the repository root (`--path src/flask`, not an absolute path). Narrow with `--lang`, `--path`, `--type`, or `--scope docs`; widen with `--limit 8`. +- Vera indexes this repository only. For dependency sources (site-packages, uv or pip caches) use rg, then return to Vera for repository code. +- A stale-index warning does not invalidate hits. After editing files, run `vera update .` from wherever you are (it uses the repository root's index; `vera index .` from the repository root if none exists). +- `vera explain-path `, `vera stats --json`, and detailed usage are in the Vera skill installed by `vera agent install`. "#; @@ -1772,7 +1776,7 @@ mod tests { #[test] fn refresh_vera_snippet_in_markdown_skips_edited_legacy_section() { let edited = legacy_agents_md_snippet().replace( - "- `vera grep \"pattern\"` for exact text or regex in indexed files", + "- Exact text or regex: `vera grep \"pattern\"`. Line numbers are always shown; cap results with `--limit `.", "- Use my preferred search tool instead", ); let existing = format!( diff --git a/crates/vera-cli/src/commands/overview.rs b/crates/vera-cli/src/commands/overview.rs index b04ebb31..1a0b7e8f 100644 --- a/crates/vera-cli/src/commands/overview.rs +++ b/crates/vera-cli/src/commands/overview.rs @@ -13,14 +13,18 @@ pub fn run( .map_err(|e| anyhow::anyhow!("failed to get current directory: {e}"))?; let config = state::load_runtime_config()?; + // An ancestor's `.vera/` index covers this directory too; without one the + // existing no-index behavior is unchanged. + let repo_root = crate::helpers::resolve_index_root(&cwd).unwrap_or_else(|| cwd.clone()); + let exact_paths = if let Some(scope) = git_scope.as_ref() { - Some(vera_core::git_scope::resolve_scope(&cwd, scope)?) + Some(vera_core::git_scope::resolve_scope(&repo_root, scope)?) } else { None }; - warn_if_index_stale(&cwd, &config.indexing); + warn_if_index_stale(&repo_root, &config.indexing); - let overview = stats::collect_overview_filtered(&cwd, exact_paths.as_ref())?; + let overview = stats::collect_overview_filtered(&repo_root, exact_paths.as_ref())?; if json_output { let json = serde_json::to_string_pretty(&overview) diff --git a/crates/vera-cli/src/commands/search.rs b/crates/vera-cli/src/commands/search.rs index e629188d..045fa55f 100644 --- a/crates/vera-cli/src/commands/search.rs +++ b/crates/vera-cli/src/commands/search.rs @@ -40,7 +40,7 @@ pub fn run( let cwd = std::env::current_dir() .map_err(|e| anyhow::anyhow!("failed to get current directory: {e}"))?; - if !vera_core::indexing::index_dir(&cwd).exists() + if crate::helpers::find_index_root(&cwd).is_none() && should_offer_auto_index( json_output, std::io::stdin().is_terminal() && std::io::stderr().is_terminal(), diff --git a/crates/vera-cli/src/commands/stats.rs b/crates/vera-cli/src/commands/stats.rs index 5d6977a1..ce755cbc 100644 --- a/crates/vera-cli/src/commands/stats.rs +++ b/crates/vera-cli/src/commands/stats.rs @@ -4,8 +4,11 @@ pub fn run(json_output: bool) -> anyhow::Result<()> { let cwd = std::env::current_dir() .map_err(|e| anyhow::anyhow!("failed to get current directory: {e}"))?; + // Match the read commands: an ancestor `.vera/` index covers this + // subdirectory too. + let repo_root = crate::helpers::resolve_index_root(&cwd).unwrap_or_else(|| cwd.clone()); - let stats = vera_core::stats::collect_stats(&cwd)?; + let stats = vera_core::stats::collect_stats(&repo_root)?; if json_output { let json = serde_json::to_string_pretty(&stats) diff --git a/crates/vera-cli/src/commands/update.rs b/crates/vera-cli/src/commands/update.rs index 692a47e0..06500d98 100644 --- a/crates/vera-cli/src/commands/update.rs +++ b/crates/vera-cli/src/commands/update.rs @@ -47,6 +47,22 @@ pub fn run(path: &str, json_output: bool, options: CommandOptions) -> anyhow::Re Hint: vera update expects a directory path, not a file." ); } + // Read commands resolve an ancestor `.vera/` index so they work from + // subdirectories; update must operate on the same root or it would + // rebuild `/.vera` as a nested partial index that shadows the + // real one for that subtree. Canonicalize first so the walk sees real + // parents instead of the raw CLI path's lexical parents ("." would + // never walk past the empty path). + let start = repo_path.canonicalize()?; + let repo_path = match crate::helpers::find_index_root(&start) { + Some(root) => { + if root != start { + eprintln!("note: using index at {}", root.display()); + } + root + } + None => repo_path.to_path_buf(), + }; let rt = tokio::runtime::Runtime::new() .map_err(|e| anyhow::anyhow!("failed to create async runtime: {e}"))?; diff --git a/crates/vera-cli/src/helpers.rs b/crates/vera-cli/src/helpers.rs index ffae4ace..06340b7f 100644 --- a/crates/vera-cli/src/helpers.rs +++ b/crates/vera-cli/src/helpers.rs @@ -64,9 +64,11 @@ pub fn warn_if_index_stale(repo_path: &Path, indexing_config: &vera_core::config match vera_core::indexing::detect_staleness(repo_path, indexing_config) { Ok(freshness) => { if let Some(warning) = freshness.stale_warning() { - let stderr = std::io::stderr(); - let mut err = stderr.lock(); - let _ = writeln!(err, "{warning}"); + print_stale_warning( + &vera_core::indexing::index_dir(repo_path), + &freshness.summary(), + &warning, + ); } } Err(err) => { @@ -75,6 +77,71 @@ pub fn warn_if_index_stale(repo_path: &Path, indexing_config: &vera_core::config } } +/// Name of the dedupe record persisted inside the index directory. +const STALE_WARNING_FILE: &str = "stale-warning.json"; +/// Window in which an identical stale warning is printed at most once. +const STALE_WARNING_DEDUP_SECS: u64 = 600; + +#[derive(serde::Deserialize)] +struct StaleWarningRecord { + summary: String, + warned_at_unix_secs: u64, +} + +/// Whether the stale-index warning should print again: only when the stored +/// summary differs from the current one or the last print is older than the +/// dedupe window. +fn stale_warning_should_print( + stored: Option<(&str, u64)>, + current_summary: &str, + now_unix_secs: u64, +) -> bool { + match stored { + Some((summary, warned_at)) => { + summary != current_summary + || now_unix_secs.saturating_sub(warned_at) >= STALE_WARNING_DEDUP_SECS + } + None => true, + } +} + +/// Print the stale-index warning, deduplicated per index: an identical warning +/// that was printed within [`STALE_WARNING_DEDUP_SECS`] is suppressed, and the +/// `{summary, warned_at_unix_secs}` record is rewritten on each print. +/// `VERA_STALE_WARNING_ALWAYS=1` bypasses the dedupe; any IO failure falls +/// back to printing. +fn print_stale_warning(index_dir: &Path, summary: &str, warning: &str) { + let always = std::env::var("VERA_STALE_WARNING_ALWAYS").is_ok_and(|v| v == "1"); + if !always { + let record_path = index_dir.join(STALE_WARNING_FILE); + let stored = std::fs::read_to_string(&record_path) + .ok() + .and_then(|data| serde_json::from_str::(&data).ok()); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if !stale_warning_should_print( + stored + .as_ref() + .map(|r| (r.summary.as_str(), r.warned_at_unix_secs)), + summary, + now, + ) { + return; + } + let stderr = std::io::stderr(); + let mut err = stderr.lock(); + let _ = writeln!(err, "{warning}"); + let record = serde_json::json!({"summary": summary, "warned_at_unix_secs": now}); + let _ = std::fs::write(&record_path, record.to_string()); + } else { + let stderr = std::io::stderr(); + let mut err = stderr.lock(); + let _ = writeln!(err, "{warning}"); + } +} + /// Whether an error represents cooperative cancellation (typed check). /// /// Thin wrapper around [`vera_core::is_cancel_error`] so CLI call sites use @@ -302,15 +369,54 @@ impl GitScopeFlags { } } +/// Walk up from `start` and return the nearest directory containing a `.vera/` +/// index. Indexed paths are stored relative to that root, so commands run in a +/// subdirectory must resolve the ancestor, not the cwd. A bare `.vera/` +/// directory is not an index: the legacy Vera home (`~/.vera`) and stray or +/// partially created directories must not match, or read commands would +/// fabricate an empty metadata store inside them. +pub fn find_index_root(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(candidate) = dir { + if vera_core::indexing::index_dir(candidate) + .join("metadata.db") + .is_file() + { + return Some(candidate.to_path_buf()); + } + dir = candidate.parent(); + } + None +} + +/// The paste-ready error shown when no `.vera/` index exists in `cwd` or any +/// parent directory. +pub fn missing_index_message(cwd: &Path) -> String { + format!( + "no index found in {} or any parent directory.\nRun `vera index .` from the repository root, then rerun this command.", + cwd.display() + ) +} + +/// Resolve the index root for `cwd` via [`find_index_root`], printing the +/// `note: using index at ` line once when the root is an ancestor. +pub fn resolve_index_root(cwd: &Path) -> Option { + let root = find_index_root(cwd)?; + if root != cwd { + eprintln!("note: using index at {}", root.display()); + } + Some(root) +} + pub fn prepare_indexed_repo( indexing_config: &vera_core::config::IndexingConfig, ) -> anyhow::Result<(PathBuf, PathBuf)> { let cwd = std::env::current_dir() .map_err(|e| anyhow::anyhow!("failed to get current directory: {e}"))?; - let index_dir = vera_core::indexing::index_dir(&cwd); - if !index_dir.exists() { - anyhow::bail!(MISSING_INDEX_MESSAGE); - } + let Some(repo_root) = resolve_index_root(&cwd) else { + anyhow::bail!(missing_index_message(&cwd)); + }; + let index_dir = vera_core::indexing::index_dir(&repo_root); // Index format version must match: legacy suffixed rows would be silently wrong. { let metadata_path = index_dir.join("metadata.db"); @@ -324,17 +430,14 @@ pub fn prepare_indexed_repo( store .get_index_meta(vera_core::indexing::freshness::INDEX_FORMAT_VERSION_KEY) .unwrap_or(None), - cwd.display() + repo_root.display() ); } } - warn_if_index_stale(&cwd, indexing_config); - Ok((cwd, index_dir)) + warn_if_index_stale(&repo_root, indexing_config); + Ok((repo_root, index_dir)) } -pub const MISSING_INDEX_MESSAGE: &str = "no index found in current directory.\n\ -Hint: run `vera index ` first to create an index."; - pub fn should_offer_auto_index(json_output: bool, is_terminal: bool) -> bool { !json_output && is_terminal } @@ -356,11 +459,104 @@ pub fn prepare_indexed_search( filters: &vera_core::types::SearchFilters, git_scope: Option<&vera_core::git_scope::GitScope>, ) -> anyhow::Result<(PathBuf, vera_core::types::SearchFilters)> { - let (cwd, index_dir) = prepare_indexed_repo(indexing_config)?; - let filters = apply_git_scope(&cwd, filters, git_scope)?; + let (repo_root, index_dir) = prepare_indexed_repo(indexing_config)?; + let mut filters = apply_git_scope(&repo_root, filters, git_scope)?; + rewrite_absolute_path_filters(&repo_root, &mut filters.path_glob); Ok((index_dir, filters)) } +/// What to do with a `--path` filter entry that turns out to be an absolute +/// path. +enum PathFilterRewrite { + /// Not an absolute path, or absolute but outside the index root: leave it + /// untouched (`path_filter_hint` covers the all-miss case). + Keep, + /// The entry pointed at the index root itself: it admits everything, so + /// drop it. + Drop, + /// The entry pointed inside the index root: replace it with the + /// root-relative form using forward slashes. + Rewrite(String), +} + +/// Rewrite one absolute `--path` entry against the candidate index roots. +/// Windows-style separators and drive-letter paths are normalized to `/` so +/// the same rule holds whatever produced the path. +fn absolute_path_filter_rewrite(pattern: &str, roots: &[PathBuf]) -> PathFilterRewrite { + absolute_path_filter_rewrite_inner(pattern, roots, cfg!(windows)) +} + +/// Comparison core for [`absolute_path_filter_rewrite`]. `case_insensitive` +/// models Windows path semantics where `C:\Repo` and `c:\repo` are the same +/// directory; the rewritten suffix is always sliced from the original pattern +/// so its casing is preserved. +fn absolute_path_filter_rewrite_inner( + pattern: &str, + roots: &[PathBuf], + case_insensitive: bool, +) -> PathFilterRewrite { + let normalized = pattern.replace('\\', "/"); + let bytes = normalized.as_bytes(); + let is_absolute = normalized.starts_with('/') + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'/'); + if !is_absolute { + return PathFilterRewrite::Keep; + } + let matches = |a: &str, b: &str| { + if case_insensitive { + a.eq_ignore_ascii_case(b) + } else { + a == b + } + }; + for root in roots { + let root = root.to_string_lossy().replace('\\', "/"); + let root = root.trim_end_matches('/'); + if matches(&normalized, root) { + return PathFilterRewrite::Drop; + } + if normalized.len() > root.len() + && normalized.as_bytes()[root.len()] == b'/' + && matches(&normalized[..root.len()], root) + { + let rel = &normalized[root.len() + 1..]; + return if rel.is_empty() { + PathFilterRewrite::Drop + } else { + PathFilterRewrite::Rewrite(rel.to_string()) + }; + } + } + PathFilterRewrite::Keep +} + +/// Rewrite every absolute `--path` entry that resolves under the index root to +/// its root-relative form, since indexed paths are stored relative to it. The +/// root is tried as-is and canonicalized so symlinked invocations work. +fn rewrite_absolute_path_filters(repo_root: &Path, patterns: &mut Vec) { + if patterns.is_empty() { + return; + } + let mut roots = vec![repo_root.to_path_buf()]; + if let Ok(canonical) = repo_root.canonicalize() + && canonical != repo_root + { + roots.push(canonical); + } + let mut rewritten = Vec::with_capacity(patterns.len()); + for pattern in patterns.drain(..) { + match absolute_path_filter_rewrite(&pattern, &roots) { + PathFilterRewrite::Keep => rewritten.push(pattern), + PathFilterRewrite::Drop => {} + PathFilterRewrite::Rewrite(rel) => rewritten.push(rel), + } + } + *patterns = rewritten; +} + impl LocalBackendFlags { pub fn any_set(&self) -> bool { self.potion_code @@ -1000,12 +1196,132 @@ mod tests { #[test] fn missing_index_message_preserves_the_cli_contract() { + let message = missing_index_message(Path::new("/repo/sub/dir")); assert_eq!( - MISSING_INDEX_MESSAGE, - "no index found in current directory.\nHint: run `vera index ` first to create an index." + message, + "no index found in /repo/sub/dir or any parent directory.\n\ + Run `vera index .` from the repository root, then rerun this command." ); } + #[test] + fn find_index_root_walks_up_to_the_nearest_index() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("repo"); + let nested = root.join("crates/foo/src"); + std::fs::create_dir_all(root.join(".vera")).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(root.join(".vera").join("metadata.db"), []).unwrap(); + let other = temp.path().join("noindex/sub"); + std::fs::create_dir_all(&other).unwrap(); + + assert_eq!(find_index_root(&root), Some(root.clone())); + assert_eq!(find_index_root(&nested), Some(root)); + assert_eq!(find_index_root(&other), None); + } + + #[test] + fn find_index_root_ignores_a_bare_vera_directory_without_an_index() { + // The legacy Vera home (`~/.vera`) and stray directories hold models + // and config, never a searchable index; they must not match, or read + // commands would fabricate an empty metadata store inside them. + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let project = home.join("src/project"); + std::fs::create_dir_all(home.join(".vera")).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + + // A `.vera` without metadata.db is not an index. + assert_eq!(find_index_root(&project), None); + assert_eq!(find_index_root(&home), None); + + // With a metadata.db it becomes one. + std::fs::write(home.join(".vera").join("metadata.db"), []).unwrap(); + assert_eq!(find_index_root(&project), Some(home.clone())); + assert_eq!(find_index_root(&home), Some(home)); + } + + #[test] + fn stale_warning_dedupe_suppresses_a_repeat_inside_the_window() { + // No record: print. + assert!(stale_warning_should_print(None, "1 added", 1_000)); + // Same summary inside the window: suppress. + assert!(!stale_warning_should_print( + Some(("1 added", 900)), + "1 added", + 1_000 + )); + // Same summary past the window: print again. + assert!(stale_warning_should_print( + Some(("1 added", 100)), + "1 added", + 1_000 + )); + // Changed summary inside the window: print. + assert!(stale_warning_should_print( + Some(("1 added", 900)), + "2 added", + 1_000 + )); + } + + #[test] + fn absolute_path_filter_rewrite_maps_under_root_and_keeps_the_rest() { + let roots = vec![PathBuf::from("/repo")]; + + // Absolute path under the root becomes root-relative. + assert!(matches!( + absolute_path_filter_rewrite("/repo/src/auth", &roots), + PathFilterRewrite::Rewrite(rel) if rel == "src/auth" + )); + // Windows-style separators are normalized to forward slashes. + assert!(matches!( + absolute_path_filter_rewrite("/repo\\src\\auth", &roots), + PathFilterRewrite::Rewrite(rel) if rel == "src/auth" + )); + assert!(matches!( + absolute_path_filter_rewrite("C:\\repo\\src", &[PathBuf::from("C:\\repo")]), + PathFilterRewrite::Rewrite(rel) if rel == "src" + )); + // Windows path comparison is case-insensitive; the rewritten glob keeps + // the pattern's own casing. + assert!(matches!( + absolute_path_filter_rewrite_inner( + "C:\\Repo\\Src", + &[PathBuf::from("c:\\repo")], + true + ), + PathFilterRewrite::Rewrite(rel) if rel == "Src" + )); + assert!(matches!( + absolute_path_filter_rewrite_inner( + "C:\\Repo\\Src", + &[PathBuf::from("c:\\repo")], + false + ), + PathFilterRewrite::Keep + )); + // The root itself becomes an empty filter and is dropped. + assert!(matches!( + absolute_path_filter_rewrite("/repo", &roots), + PathFilterRewrite::Drop + )); + assert!(matches!( + absolute_path_filter_rewrite("/repo/", &roots), + PathFilterRewrite::Drop + )); + // Absolute path outside the root is left for `path_filter_hint`. + assert!(matches!( + absolute_path_filter_rewrite("/other/src", &roots), + PathFilterRewrite::Keep + )); + // Relative patterns pass through untouched. + assert!(matches!( + absolute_path_filter_rewrite("src/**", &roots), + PathFilterRewrite::Keep + )); + } + #[tokio::test] async fn ready_operation_error_wins_over_ready_signal() { for _ in 0..64 { diff --git a/crates/vera-cli/src/main.rs b/crates/vera-cli/src/main.rs index 99c6cbf0..85511db2 100644 --- a/crates/vera-cli/src/main.rs +++ b/crates/vera-cli/src/main.rs @@ -34,7 +34,33 @@ fn main() { .init(); vera_core::init_tls(); - let cli = Cli::parse(); + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(err) => { + // Agents habitually type `vera grep foo -n` (grep's line-number + // flag), which clap rejects because `-n` expects a value. Give + // that case one extra hint line; everything else keeps clap's + // standard behavior (including --help/--version exiting 0). + // Help/version errors must not take this path: their Display + // output is the full help text, which contains `--limit` for + // grep/search/structural/references and would print the hint + // after a perfectly good `--help`. + let is_help_or_version = matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelp + | clap::error::ErrorKind::DisplayVersion + | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ); + if !is_help_or_version && err.to_string().contains("--limit") { + let _ = err.print(); + eprintln!( + "hint: in vera, -n is short for --limit ; line numbers are always shown. Put -n after the pattern or use --limit." + ); + std::process::exit(err.exit_code()); + } + err.exit(); + } + }; if let Err(err) = state::apply_saved_env() { // Diagnose/repair commands must still run against a broken saved // config; everything else keeps failing fast with the parse error. @@ -807,6 +833,29 @@ mod tests { assert!(cli.raw); } + #[test] + fn cli_parses_grep_bare_n_as_default_limit() { + let cli = Cli::parse_from(["vera", "grep", "TODO", "-n"]); + match cli.command { + Commands::Grep { limit, .. } => assert_eq!(limit, Some(20)), + _ => panic!("expected Grep command"), + } + } + + #[test] + fn cli_parses_grep_n_with_value() { + let cli = Cli::parse_from(["vera", "grep", "TODO", "-n", "7"]); + match cli.command { + Commands::Grep { limit, .. } => assert_eq!(limit, Some(7)), + _ => panic!("expected Grep command"), + } + let cli = Cli::parse_from(["vera", "grep", "TODO", "--limit", "30"]); + match cli.command { + Commands::Grep { limit, .. } => assert_eq!(limit, Some(30)), + _ => panic!("expected Grep command"), + } + } + #[test] fn cli_parses_structural_definitions_command() { match parse(&["vera", "structural", "definitions", "parse_config"]) { diff --git a/crates/vera-cli/src/update_check.rs b/crates/vera-cli/src/update_check.rs index 2729ce1d..79c8bd36 100644 --- a/crates/vera-cli/src/update_check.rs +++ b/crates/vera-cli/src/update_check.rs @@ -240,10 +240,19 @@ fn cache_path() -> Option { .map(|dir| dir.join("update-check.json")) } +/// Whether the "vera vX is available" hint should print: only on an +/// interactive terminal, so agent sessions with piped stderr do not get a hint +/// appended to every result. +fn should_print_binary_hint(stderr_is_terminal: bool) -> bool { + stderr_is_terminal +} + fn check_binary_staleness() { + use std::io::IsTerminal; let status = binary_version_status(false); if let Some(latest) = status.latest_version.as_deref() && status.update_available() + && should_print_binary_hint(std::io::stderr().is_terminal()) { print_binary_nudge(latest, &status); } @@ -727,6 +736,12 @@ mod tests { path } + #[test] + fn binary_hint_only_prints_on_a_terminal() { + assert!(should_print_binary_hint(true)); + assert!(!should_print_binary_hint(false)); + } + #[test] fn fresh_cache_with_install_method_skips_detection() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/vera-core/src/retrieval/reranker.rs b/crates/vera-core/src/retrieval/reranker.rs index c81ae3d8..dd09d43d 100644 --- a/crates/vera-core/src/retrieval/reranker.rs +++ b/crates/vera-core/src/retrieval/reranker.rs @@ -355,7 +355,7 @@ impl ApiReranker { if matches!(e, RerankerError::AuthError { .. }) || !is_retryable_error(&e) { return Err(e); } - warn!( + debug!( attempt = attempt + 1, max = self.config.max_retries + 1, error = %e, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dd1186e7..ada3993f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,13 +2,13 @@ ## No index found -Either the repository hasn't been indexed yet, or you're running the command from the wrong directory. +Either the repository hasn't been indexed yet, or you're running the command outside any indexed directory. ```bash vera index . ``` -Make sure you're in the repository root (the directory containing `.vera/`). +Run it from the repository root (the directory where `.vera/` should live). Search, grep, structural, references, overview, stats, and update commands resolve the nearest `.vera/` up the directory tree, so they also work from subdirectories. ## Results are stale @@ -18,6 +18,8 @@ Code changed after the last index. Update it: vera update . ``` +The update runs from any subdirectory and refreshes the repository root's index. An identical stale warning is printed at most once every 10 minutes per index; set `VERA_STALE_WARNING_ALWAYS=1` to see it on every run. + ## Results are irrelevant Try narrowing your search: diff --git a/skills/vera/SKILL.md b/skills/vera/SKILL.md index 8f289d86..edf7dacf 100644 --- a/skills/vera/SKILL.md +++ b/skills/vera/SKILL.md @@ -1,57 +1,64 @@ --- name: vera -description: Code search over the current repository. Before reading files to answer "where is X", "how does Y work", "find Z", or "what calls W", run `vera search ""` or `vera references ` first. Use `vera grep` for exact strings and regex, `vera structural` for definitions, routes, and env reads. Do not read multiple files hoping to find the right one; search first, then read the hit. +description: Code search over the current repository. Your first search in a repository goes through Vera, not grep, rg, or file reads. Run `vera overview` in an unfamiliar repository, `vera search ""` for how or where something works, `vera structural definitions ` or `vera references ` when the question names a symbol, and `vera grep "pattern"` for exact text or regex. Fall back to grep only after a Vera call has missed. --- # Vera Ranked code search over an indexed repository. Results are markdown codeblocks: `path:line_start-line_end symbol_type:symbol_name` (split symbols render as `name (part N)` in that position, the bare name plus the part suffix), then the code. -## Pick the tool +## First search -| You are about to... | Do this instead | -|---------------------|-----------------| -| Read files to find where something lives | `vera search "config object construction"` | -| Read files to understand how something works | `vera search "env file loading decision"` | -| Find documentation on a topic | `vera search "deploying behind proxy" --scope docs` | -| Find every occurrence of a pattern | `vera grep "TODO|FIXME"` | -| `vera grep` with `--path` matches nothing | Check stderr: when every `--path` pattern matches zero indexed files, Vera suggests a wildcard directory alternative (for example `src/**/` instead of `src/`) | -| Find callers or callees of a symbol | `vera references make_config` | -| Find definitions, routes, env reads | `vera structural env` / `vera structural routes` | -| Edit the same pattern in many files | `rg` | -| Read a file you already know | Read it directly | +The first search action in a task decides whether Vera gets used at all; later searches follow the first tool chosen. So: -`vera references` resolves split-symbol call sites, `vera structural definitions` finds split symbols by bare name and deduplicates to the earliest declaration, and `vera dead-code` deduplicates split parts by (symbol, file). +1. Unfamiliar repository: `vera overview` (languages, entry points, hotspots) instead of `ls` and README skimming. +2. Then the question's first lookup goes through the table below. Do not "check with grep first". -## Do not use Vera when +## Pick the tool -- You already know the exact path and line: open the file. -- You are editing across many files mechanically: use `rg`. -- The answer is a literal string you can match: `vera grep` beats `vera search`. -- You have already run two searches that returned the same region: stop searching and read the code. +| Question shape | Do this | +|----------------|---------| +| How or where does something work | `vera search "env file loading decision"` | +| The question names a function, class, or config key | `vera structural definitions make_config`, then `vera references make_config` for callers | +| Who calls or what is called by a symbol | `vera references make_config` / `vera references make_config --callees` | +| Exact text or regex | `vera grep "before_request"` (line numbers always shown; `--limit ` caps results) | +| Enumerate every route, env read, implementation | `vera structural routes` / `vera structural env` / `vera structural impls ` | +| Documentation on a topic | `vera search "deploying behind proxy" --scope docs` | +| Files changed in this branch | add `--changed`, `--since `, or `--base ` to any of the above | +| Edit the same pattern in many files mechanically | `rg` | +| Read a file you already know the path and lines of | Read it directly | + +`vera references` resolves split-symbol call sites, `vera structural definitions` finds split symbols by bare name and deduplicates to the earliest declaration, and `vera dead-code` deduplicates split parts by (symbol, file). ## Search well - Search behavior, not nouns: `"JWT expiry handling"`, not `"auth"` or `"utils"`. - Pass several angles in one call: `vera search "OAuth token refresh" "JWT expiry" "auth middleware"`. - Start broad with `--compact` (signatures only, fewer tokens), then narrow with `--lang`, `--path`, `--type`, `--limit`. +- `--path` is relative to the repository root: `--path src/flask`, `--path "tests/**/*.py"`. Absolute paths inside the repository are accepted and rewritten; paths outside it match nothing. - Add `--intent ""` when the query is vague but the goal is clear. -- Scope to a change with `--changed`, `--since `, or `--base ` when reviewing a diff. -- `--deep` rewrites the query through an LLM; use it only after normal search misses. +- `--deep` expands the query and merges rankings; use it only after normal search misses. + +## Recover from a miss + +- Top hit is a usage site, not the definition: `vera structural definitions ` with the name from the hit. Do not try more phrasings. +- Hit shows a call but not the caller chain: `vera references `. +- Two searches returned the same region: stop searching and read that code. +- The code is in a dependency (site-packages, uv or pip caches, vendored trees outside the index): use `rg` there, then return to Vera for repository code. ## Treat hits as leads -- A search hit is a lead, not evidence. Before stating how something behaves, open the cited lines. -- Follow the call graph rather than re-searching: `vera references ` on a promising hit answers "who drives this" in one step. +- A hit is a lead, not evidence. Verify behavior against the hit's code; open the file only for lines the hit did not include. - Cite `path:line` from code you actually read. -- After editing files, run `vera update .` before searching again. +- A stale-index warning does not invalidate hits. After editing files, run `vera update .` before searching again; it updates the repository root's index from any subdirectory. ## Recovery | Symptom | Fix | |---------|-----| -| `no index found` | `vera index .` | -| Stale results after edits | `vera update .` (or `vera watch .`) | +| `no index found` | `vera index .` from the repository root, then rerun the search | +| `no indexed file matches ` | Use a root-relative `--path`; stderr suggests a wildcard directory form when one applies | +| Stale results after edits | `vera update .` (or `vera watch .`); works from any subdirectory | | A file is missing from results | `vera explain-path path/to/file` | | Local model or ONNX error | `vera doctor --probe`, then `references/troubleshooting.md` | | Missing local assets | `vera repair` | diff --git a/skills/vera/references/troubleshooting.md b/skills/vera/references/troubleshooting.md index 508131b0..49161c05 100644 --- a/skills/vera/references/troubleshooting.md +++ b/skills/vera/references/troubleshooting.md @@ -1,11 +1,11 @@ # Troubleshooting -## `no index found in current directory` +## `no index found ... or any parent directory` Cause: - the repository has not been indexed yet -- the command is running from the wrong directory +- the command is running outside any indexed directory Fix: @@ -13,7 +13,7 @@ Fix: vera index . ``` -Or run from the repository root that contains `.vera/`. +Run it from the repository root. Search, grep, structural, references, overview, stats, and update commands resolve the nearest `.vera/` up the directory tree, so they work from subdirectories too. ## Results Are Stale @@ -27,6 +27,8 @@ Fix: vera update . ``` +Works from any subdirectory; it refreshes the repository root's index. + ## Local ONNX Inference Fails Check: