From f97e06e64fe69354521cac285a609ffb70fd03b0 Mon Sep 17 00:00:00 2001 From: sting8k Date: Tue, 26 May 2026 13:40:37 +0700 Subject: [PATCH] feat: add semantic directory orientation --- CHANGELOG.md | 1 + README.md | 3 +- skills/srcwalk/GUIDE.md | 1 + src/map.rs | 280 +++++++++++++++++++++++++++------------- src/read/directory.rs | 44 ++++++- tests/map_output.rs | 4 +- tests/path_exact.rs | 27 ++++ 7 files changed, 263 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b61264d..23d6f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to srcwalk are documented here. ## Unreleased ### Changed +- Added semantic drilldown footers to directory reads and made `overview --symbols` emit budget-adaptive inline `kind name@line-range` anchors before falling back to compact symbol names. - Extended `show -C/--context-lines` to line ranges, resolved sections, and comma-separated show/section targets; comma-separated multi reads clamp each target to 10 context lines. - Updated discover next-step guidance to prefer confirmed `context` targets when structural candidates exist and raw `show : -C 10` reads for text hits. - Reframed the embedded agent guide as an evidence contract with explicit `srcwalk`-before-`rg` routing and comma-separated literal OR discovery guidance. diff --git a/README.md b/README.md index 0e3f26e..648eb10 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,9 @@ srcwalk assess validateToken --scope src/ srcwalk deps src/auth.ts srcwalk deps docs/guide.md # Markdown/HTML links and assets -# Overview +# Overview / semantic directory orientation srcwalk overview --scope src/ +srcwalk overview --scope src/ --symbols # inline symbol kind/range anchors when budget allows ``` Discovery commands respect ignore files; explicit file reads can still inspect ignored paths. diff --git a/skills/srcwalk/GUIDE.md b/skills/srcwalk/GUIDE.md index ffef562..e9d1e89 100644 --- a/skills/srcwalk/GUIDE.md +++ b/skills/srcwalk/GUIDE.md @@ -72,6 +72,7 @@ srcwalk context --scope ``` Use auto overview depth first; explicit `--depth N` is strict. `[relations]` are static local dependency groups, not runtime calls. `[outbound deps]` imports targets outside `--scope`. +`overview --symbols` may show inline `kind name@line-range` anchors when budget allows; if output is too large it falls back to fewer anchors or compact symbol names. `discover` only searches inside `--scope`; narrow scopes can hide definitions. After a first pass, use `--expand=3`, `--filter kind:fn`, or `--exclude 'tests/**'` only when the output is too broad. diff --git a/src/map.rs b/src/map.rs index 012946a..329d8fb 100644 --- a/src/map.rs +++ b/src/map.rs @@ -18,6 +18,8 @@ const WIDE_SCOPE_FILE_THRESHOLD: usize = 100; const MAX_ARTIFACT_MAP_FILES: usize = 40; const MAX_ARTIFACT_MAP_ANCHORS_PER_FILE: usize = 6; const MAX_OUTBOUND_RELATION_GROUPS: usize = 10; +const PRIMARY_SYMBOL_ANCHOR_LIMIT: usize = 3; +const FALLBACK_SYMBOL_ANCHOR_LIMIT: usize = 2; struct WalkConfig { hidden: bool, @@ -312,7 +314,7 @@ fn generate_at_depth( outline::generate(path, file_type, &content, buf, true) }); - Some(extract_symbol_names(&outline_str)) + Some(extract_symbol_previews(&outline_str)) } _ => None, } @@ -347,53 +349,106 @@ fn generate_at_depth( } } - let mut base = format!( - "# Overview: {} (depth {}, sizes ~= tokens)\n", - crate::format::display_path(scope), - depth_label - ); - if depth_reduced { - base.push_str("# Note: depth reduced to fit cap.\n"); - } - base.push_str(&format_walk_note(cfg, artifact)); - format_tree(&tree, &totals, Path::new(""), 0, &mut base); - let relations = compute_relations(scope, depth, &visible_files); let outbound_relations = if relations.is_empty() { compute_outbound_relations(scope, depth, &visible_files) } else { Vec::new() }; - let mut out = base.clone(); - if !relations.is_empty() { - format_relations(&relations, &mut out); - } else if !outbound_relations.is_empty() { - format_outbound_relations(&outbound_relations, &mut out); - } - append_map_footer( - &mut out, - artifact, - include_symbols, - relations.is_empty() && outbound_relations.is_empty() && visible_files.len() > 1, - !outbound_relations.is_empty(), - ); - if enforce_hard_cap(&out, scope, depth).is_ok() { - return Ok(out); + + let symbol_modes = if include_symbols { + vec![ + SymbolRenderMode::Anchored { + limit: PRIMARY_SYMBOL_ANCHOR_LIMIT, + }, + SymbolRenderMode::Anchored { + limit: FALLBACK_SYMBOL_ANCHOR_LIMIT, + }, + SymbolRenderMode::Compact, + ] + } else { + vec![SymbolRenderMode::Compact] + }; + + let mut last_output = String::new(); + for symbol_mode in &symbol_modes { + let mut out = format_overview_base( + scope, + depth_label, + depth_reduced, + cfg, + artifact, + &tree, + &totals, + *symbol_mode, + ); + if !relations.is_empty() { + format_relations(&relations, &mut out); + } else if !outbound_relations.is_empty() { + format_outbound_relations(&outbound_relations, &mut out); + } + append_map_footer( + &mut out, + artifact, + include_symbols, + relations.is_empty() && outbound_relations.is_empty() && visible_files.len() > 1, + !outbound_relations.is_empty(), + ); + if enforce_hard_cap(&out, scope, depth).is_ok() { + return Ok(out); + } + last_output = out; } if !relations.is_empty() { - let mut degraded = base; - let _ = writeln!( - degraded, - "\n# Note: relations omitted to fit {MAP_HARD_TOKEN_CAP} token cap; narrow --scope/--depth for relations." - ); - append_map_footer(&mut degraded, artifact, include_symbols, false, false); - enforce_hard_cap(°raded, scope, depth)?; - return Ok(degraded); + for symbol_mode in &symbol_modes { + let mut degraded = format_overview_base( + scope, + depth_label, + depth_reduced, + cfg, + artifact, + &tree, + &totals, + *symbol_mode, + ); + let _ = writeln!( + degraded, + "\n# Note: relations omitted to fit {MAP_HARD_TOKEN_CAP} token cap; narrow --scope/--depth for relations." + ); + append_map_footer(&mut degraded, artifact, include_symbols, false, false); + if enforce_hard_cap(°raded, scope, depth).is_ok() { + return Ok(degraded); + } + last_output = degraded; + } } - enforce_hard_cap(&out, scope, depth)?; - Ok(out) + enforce_hard_cap(&last_output, scope, depth)?; + Ok(last_output) +} + +fn format_overview_base( + scope: &Path, + depth_label: &str, + depth_reduced: bool, + cfg: &WalkConfig, + artifact: ArtifactMode, + tree: &BTreeMap>, + totals: &BTreeMap, + symbol_mode: SymbolRenderMode, +) -> String { + let mut base = format!( + "# Overview: {} (depth {}, sizes ~= tokens)\n", + crate::format::display_path(scope), + depth_label + ); + if depth_reduced { + base.push_str("# Note: depth reduced to fit cap.\n"); + } + base.push_str(&format_walk_note(cfg, artifact)); + format_tree(tree, totals, Path::new(""), 0, &mut base, symbol_mode); + base } fn append_map_footer( @@ -943,11 +998,24 @@ fn add_dir_rollup( struct FileEntry { name: String, - symbols: Option>, + symbols: Option>, artifact_anchors: Option, tokens: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct SymbolPreview { + kind: &'static str, + name: String, + range: String, +} + +#[derive(Clone, Copy)] +enum SymbolRenderMode { + Anchored { limit: usize }, + Compact, +} + struct ArtifactMapAnchors { anchors: Vec, omitted: usize, @@ -969,67 +1037,109 @@ fn artifact_map_anchors(path: &Path) -> Option { }) } -/// Extract symbol names from an outline string. -/// Outline lines look like: `[7-57] fn classify` -/// We extract the last word(s) after the kind keyword. -fn extract_symbol_names(outline: &str) -> Vec { - let mut names = Vec::new(); +/// Extract bounded symbol previews from an outline string. +/// Outline lines look like: `[7-57] fn classify`. +fn extract_symbol_previews(outline: &str) -> Vec { + let mut symbols = Vec::new(); for line in outline.lines() { let trimmed = line.trim(); - // Skip import lines and empty lines - if trimmed.starts_with('[') { - // Find the symbol name after kind keywords - if let Some(sig_start) = find_symbol_start(trimmed) { - let sig = &trimmed[sig_start..]; - // Take just the name (up to first paren or space after name) - let name = extract_name_from_sig(sig); - if !name.is_empty() && name != "imports" { - names.push(name); - } - } + let Some(rest) = trimmed.strip_prefix('[') else { + continue; + }; + let Some(range_end) = rest.find(']') else { + continue; + }; + let range = &rest[..range_end]; + let body = rest[range_end + 1..].trim_start(); + let Some((kind, sig)) = split_symbol_kind(body) else { + continue; + }; + let name = extract_name_from_sig(sig); + if !name.is_empty() && name != "imports" { + symbols.push(SymbolPreview { + kind, + name, + range: range.to_string(), + }); } } - names + symbols } -fn find_symbol_start(line: &str) -> Option { - let kinds = [ - "fn ", - "struct ", - "enum ", - "trait ", - "impl ", - "mod ", - "class ", - "interface ", - "type ", - "const ", - "static ", - "function ", - "method ", - "def ", +fn split_symbol_kind(line: &str) -> Option<(&'static str, &str)> { + const KINDS: &[(&str, &str)] = &[ + ("fn", "fn "), + ("struct", "struct "), + ("enum", "enum "), + ("trait", "trait "), + ("impl", "impl "), + ("mod", "mod "), + ("class", "class "), + ("interface", "interface "), + ("type", "type "), + ("const", "const "), + ("static", "static "), + ("function", "function "), + ("method", "method "), + ("def", "def "), ]; - for kind in &kinds { - if let Some(pos) = line.find(kind) { - return Some(pos + kind.len()); - } - } - None + KINDS + .iter() + .find_map(|(kind, prefix)| line.strip_prefix(prefix).map(|sig| (*kind, sig))) } fn extract_name_from_sig(sig: &str) -> String { - // Take characters until we hit a non-identifier char sig.chars() .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$') .collect() } +fn format_symbol_preview(symbols: &[SymbolPreview], mode: SymbolRenderMode) -> Option { + if symbols.is_empty() { + return None; + } + + match mode { + SymbolRenderMode::Anchored { limit } => Some(format_anchored_symbols(symbols, limit)), + SymbolRenderMode::Compact => Some(format_compact_symbols(symbols)), + } +} + +fn format_anchored_symbols(symbols: &[SymbolPreview], limit: usize) -> String { + let shown = symbols + .iter() + .take(limit) + .map(|symbol| format!("{} {}@{}", symbol.kind, symbol.name, symbol.range)) + .collect::>() + .join(", "); + let omitted = symbols.len().saturating_sub(limit); + if omitted > 0 { + format!("{shown}, ... +{omitted}") + } else { + shown + } +} + +fn format_compact_symbols(symbols: &[SymbolPreview]) -> String { + let syms = symbols + .iter() + .map(|symbol| symbol.name.as_str()) + .collect::>() + .join(", "); + if syms.len() > 80 { + format!("{}...", crate::types::truncate_str(&syms, 77)) + } else { + syms + } +} + fn format_tree( tree: &BTreeMap>, totals: &BTreeMap, dir: &Path, indent: usize, out: &mut String, + symbol_mode: SymbolRenderMode, ) { // Show directories first, largest first, so truncated maps keep the // highest-signal navigation scaffold near the top. @@ -1049,7 +1159,7 @@ fn format_tree( let dir_name = subdir.file_name().and_then(|n| n.to_str()).unwrap_or("?"); let total = totals.get(subdir).copied().unwrap_or(0); let _ = writeln!(out, "{prefix}{dir_name}/ ~{}", fmt_tokens(total)); - format_tree(tree, totals, subdir, indent + 1, out); + format_tree(tree, totals, subdir, indent + 1, out, symbol_mode); } if let Some(files) = tree.get(dir) { @@ -1058,16 +1168,10 @@ fn format_tree( for f in files { if let Some(ref symbols) = f.symbols { - if symbols.is_empty() { - let _ = writeln!(out, "{prefix}{} ~{}", f.name, fmt_tokens(f.tokens)); + if let Some(preview) = format_symbol_preview(symbols, symbol_mode) { + let _ = writeln!(out, "{prefix}{}: {preview}", f.name); } else { - let syms = symbols.join(", "); - let truncated = if syms.len() > 80 { - format!("{}...", crate::types::truncate_str(&syms, 77)) - } else { - syms - }; - let _ = writeln!(out, "{prefix}{}: {truncated}", f.name); + let _ = writeln!(out, "{prefix}{} ~{}", f.name, fmt_tokens(f.tokens)); } } else { let _ = writeln!(out, "{prefix}{} ~{}", f.name, fmt_tokens(f.tokens)); diff --git a/src/read/directory.rs b/src/read/directory.rs index 6b45c2f..48a7788 100644 --- a/src/read/directory.rs +++ b/src/read/directory.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::Path; use crate::error::SrcwalkError; +use crate::evidence::{render_next_actions, NextAction}; use crate::format; use crate::types::estimate_tokens; @@ -26,16 +27,47 @@ pub(super) fn list_directory(path: &Path) -> Result { Some(t) if t.is_dir() => "/".to_string(), Some(t) if t.is_symlink() => " →".to_string(), _ => match meta { - Some(m) => { - let tokens = estimate_tokens(m.len()); - format!(" ({tokens} tokens)") - } + Some(m) => format!(" ~{}", fmt_tokens(estimate_tokens(m.len()))), None => String::new(), }, }; entries.push(format!(" {name}{suffix}")); } - let header = format!("# {} ({} items)", format::display_path(path), items.len()); - Ok(format!("{header}\n\n{}", entries.join("\n"))) + let display_path = format::display_path(path); + let header = format!( + "# {} ({} items, sizes ~= tokens)", + display_path, + items.len() + ); + let mut out = format!("{header}\n\n{}", entries.join("\n")); + let next_actions = render_next_actions(&[ + NextAction::guidance( + format!("srcwalk overview --scope {display_path} --symbols"), + "directory code structure drilldown", + 40, + ), + NextAction::guidance( + format!("srcwalk discover --scope {display_path}"), + "directory symbol discovery drilldown", + 50, + ), + ]); + if !next_actions.is_empty() { + out.push_str("\n\n"); + out.push_str(&next_actions); + } + Ok(out) +} + +fn fmt_tokens(n: u64) -> String { + #[allow(clippy::cast_precision_loss)] // display-only; mantissa loss is fine for summaries + let f = n as f64; + if n >= 1_000_000 { + format!("{:.1}M", f / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}k", f / 1_000.0) + } else { + n.to_string() + } } diff --git a/tests/map_output.rs b/tests/map_output.rs index eef28b1..c6146e0 100644 --- a/tests/map_output.rs +++ b/tests/map_output.rs @@ -1010,8 +1010,8 @@ fn map_symbols_includes_symbol_names() { ); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("lib.rs: alpha, beta"), - "expected symbol names with --symbols, got:\n{stdout}" + stdout.contains("lib.rs: fn alpha@1, fn beta@2"), + "expected anchored symbol previews with --symbols, got:\n{stdout}" ); assert!( stdout.contains("> Next: narrow with --scope "), diff --git a/tests/path_exact.rs b/tests/path_exact.rs index 0626dc9..b8d9a22 100644 --- a/tests/path_exact.rs +++ b/tests/path_exact.rs @@ -132,6 +132,33 @@ fn root_path_shortcut_reads_file_without_fallback() { let _ = fs::remove_dir_all(&dir); } +#[test] +fn directory_path_read_routes_to_semantic_overview() { + let dir = temp_repo("path_directory_footer"); + let src = dir.join("src"); + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("lib.rs"), "pub fn alpha() {}\n").unwrap(); + + let out = srcwalk().arg(&src).output().unwrap(); + + assert!(out.status.success(), "expected directory read to succeed"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("lib.rs") && stdout.contains("> Next: srcwalk overview --scope"), + "expected directory listing plus semantic overview next step, got:\n{stdout}" + ); + assert!( + stdout.contains("sizes ~= tokens") && !stdout.contains("lib.rs ("), + "expected one header-level token unit instead of repeated row token labels, got:\n{stdout}" + ); + assert!( + stdout.contains("--symbols") && stdout.contains("srcwalk discover --scope"), + "expected symbol-oriented directory next steps, got:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + #[test] fn root_missing_path_fails_fast() { let dir = temp_repo("path_exact_missing");