From 49b08d6f49a95a1741b8a4ce7a3256d9d7349b18 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 09:24:51 +0700 Subject: [PATCH 1/9] Fix CLI contract consistency --- README.md | 4 ++ skills/srcwalk/GUIDE.md | 8 +-- src/cli.rs | 2 +- src/commands/callees.rs | 78 ++++++++++++++++++++++++++--- src/commands/impact.rs | 8 ++- src/map.rs | 32 ++++++++++++ src/search/display/mod.rs | 4 +- tests/callees_detailed.rs | 91 ++++++++++++++++++++++++++++++++++ tests/fixtures/callees/main.rs | 23 +++++++++ tests/impact_output.rs | 13 ++++- tests/intent_first_cli.rs | 45 ++++++++++++++++- tests/map_output.rs | 34 +++++++++++++ tests/subcommand_aliases.rs | 17 +++++++ 13 files changed, 339 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c16f3d4..9d31c2e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ See [`CHANGELOG.md`](./CHANGELOG.md) for curated release notes. Maintainers shou Agent routing order lives in `srcwalk guide`; examples below are command reference, not a workflow. ```sh +# Version / installed binary check +srcwalk --version +srcwalk version --check + # Read a file (structural view by default; raw pages are explicit) srcwalk src/auth.ts srcwalk src/auth.ts:72 # drill into exact hit line diff --git a/skills/srcwalk/GUIDE.md b/skills/srcwalk/GUIDE.md index e9d1e89..727854d 100644 --- a/skills/srcwalk/GUIDE.md +++ b/skills/srcwalk/GUIDE.md @@ -33,9 +33,9 @@ Use the smallest subset of this flow that proves the task. For broad, unfamiliar request / bug / feature question -> srcwalk overview --scope -> srcwalk discover --scope - -> pick one plausible target from discovery output - -> srcwalk context --scope - -> srcwalk show : + -> if output prints `## Confirmed next context targets`, run the matching `srcwalk context :` + -> otherwise read exact evidence with `srcwalk show : -C 10` + -> use `srcwalk show :` for exact source drilldown -> srcwalk trace callers --scope -> srcwalk trace callees --detailed --scope -> srcwalk deps @@ -187,7 +187,7 @@ srcwalk dist/app.min.js --artifact # artifact-level outline for bundled/minifie ## Supported structural languages -Code/source structure: Rust, TypeScript/TSX, JavaScript, Python, Go, Java/Scala/Kotlin, C/C++, Ruby, PHP, C#, Swift, Elixir, CSS/SCSS/Less. +Code/source structure varies by command: Rust, TypeScript/TSX, JavaScript, Python, Go, Java/Scala/Kotlin, C/C++, Ruby, PHP, C#, Swift, Elixir, CSS/SCSS/Less. `context`/Flow Map support is narrower: trust confirmed context targets emitted by srcwalk, and otherwise use `show`, `trace`, or `deps` instead of guessing command support. Documents: HTML/HTM plus Markdown-style `.md`, `.mdx`, `.rst` fallback. Covers sections, elements, code blocks, links, assets. Treat document output as navigation evidence, not rendered or runtime proof. diff --git a/src/cli.rs b/src/cli.rs index d342794..ecc0678 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,7 @@ use srcwalk::ArtifactMode; /// srcwalk — Tree-sitter indexed lookups, smart code reading for AI agents. /// Run `srcwalk guide` for the embedded, version-matched agent guide. #[derive(Parser)] -#[command(name = "srcwalk", about, after_help = ROOT_HELP)] +#[command(name = "srcwalk", about, version, after_help = ROOT_HELP)] pub(crate) struct Cli { #[command(subcommand)] pub(crate) command: Option, diff --git a/src/commands/callees.rs b/src/commands/callees.rs index a562c46..1529ea2 100644 --- a/src/commands/callees.rs +++ b/src/commands/callees.rs @@ -230,14 +230,17 @@ pub(crate) fn run_callees_with_artifact( } if !unresolved.is_empty() { - out.push_str("\n\n (unresolved): "); - out.push_str( - &unresolved - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", "), - ); + let sites = if artifact.enabled() { + search::callees::extract_call_sites_for_artifact_target( + &content, + lang, + target, + def_match.def_range, + ) + } else { + search::callees::extract_call_sites(&content, lang, def_match.def_range) + }; + append_unresolved_call_site_evidence(&mut out, &unresolved, &sites); } let rendered = render_next_actions(&[NextAction::guidance( @@ -260,6 +263,65 @@ pub(crate) fn run_callees_with_artifact( Ok(output) } +fn append_unresolved_call_site_evidence( + out: &mut String, + unresolved: &[&String], + sites: &[search::callees::CallSite], +) { + const LIMIT: usize = 12; + let unresolved_names = unresolved + .iter() + .map(|name| name.as_str()) + .collect::>(); + let mut unresolved_sites = sites + .iter() + .filter(|site| unresolved_names.contains(site.callee.as_str())) + .collect::>(); + unresolved_sites.sort_by_key(|site| (site.line, site.callee.as_str())); + + if unresolved_sites.is_empty() { + out.push_str("\n\n (unresolved; call-site reason not classified): "); + out.push_str( + &unresolved + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "), + ); + return; + } + + out.push_str("\n\n unresolved call sites (reason not classified):"); + let rendered_names = unresolved_sites + .iter() + .take(LIMIT) + .map(|site| site.callee.as_str()) + .collect::>(); + for site in unresolved_sites.iter().take(LIMIT) { + let _ = write!(out, "\n {}", format_call_site(site)); + } + if unresolved_sites.len() > LIMIT { + let _ = write!( + out, + "\n ... {} more unresolved call sites", + unresolved_sites.len() - LIMIT + ); + } + + let unrendered_names = unresolved + .iter() + .map(|name| name.as_str()) + .filter(|name| !rendered_names.contains(*name)) + .collect::>(); + if !unrendered_names.is_empty() { + let _ = write!( + out, + "\n unresolved names without call-site rows: {}", + unrendered_names.join(", ") + ); + } +} + fn format_artifact_call_site(site: &search::callees::CallSite, content: &str) -> String { let mut out = format_call_site(site); let Some((start_byte, end_byte)) = site.call_byte_range else { diff --git a/src/commands/impact.rs b/src/commands/impact.rs index 7504e01..e264ba1 100644 --- a/src/commands/impact.rs +++ b/src/commands/impact.rs @@ -412,9 +412,10 @@ pub(crate) fn run_impact( out.push_str("\n> Warning: broad symbol name; assess is name-matched and may include unrelated receivers."); } + let scope_display = format::display_path(scope); let _ = write!( out, - "\n> Caveat: {total_callers} direct name-matched call site{} found; heuristic assess output capped.", + "\n> Caveat: {total_callers} direct name-matched call site{} found inside `{scope_display}`; heuristic assess output capped.", if total_callers == 1 { "" } else { "s" } ); let rendered = render_next_actions(&[NextAction::guidance( @@ -427,7 +428,10 @@ pub(crate) fn run_impact( out.push_str(&rendered); } if total_callers == 0 { - out.push_str("\n> Note: no direct name-matched calls found in scope; this is not proof of no runtime callers."); + out.push_str("\n> Note: no direct name-matched calls found inside the selected scope; this is not proof of no runtime callers."); + out.push_str( + "\n> Next: if callers may live outside this scope, retry `srcwalk trace callers --scope `.", + ); } out.push_str("\n> Note: direct-name scope scan; misses dynamic dispatch, reflection, generated/ignored, out-of-scope callers."); Ok(apply_optional_budget(out, budget_tokens)) diff --git a/src/map.rs b/src/map.rs index 329d8fb..996250f 100644 --- a/src/map.rs +++ b/src/map.rs @@ -349,6 +349,22 @@ fn generate_at_depth( } } + if visible_files.is_empty() { + let mut out = format_overview_base( + scope, + depth_label, + depth_reduced, + cfg, + artifact, + &tree, + &totals, + SymbolRenderMode::Compact, + ); + append_empty_overview_diagnostics(&mut out, scope); + enforce_hard_cap(&out, scope, depth)?; + return Ok(out); + } + let relations = compute_relations(scope, depth, &visible_files); let outbound_relations = if relations.is_empty() { compute_outbound_relations(scope, depth, &visible_files) @@ -451,6 +467,22 @@ fn format_overview_base( base } +fn append_empty_overview_diagnostics(out: &mut String, scope: &Path) { + let display = crate::format::display_path(scope); + let _ = writeln!( + out, + "\nNo overview entries found under `{display}`. The scope may be empty, ignored, unsupported, or filtered." + ); + let rendered = render_next_actions(&[NextAction::guidance( + "inspect the parent scope, or read a known file with `srcwalk show `.", + "overview empty-scope drilldown", + 40, + )]); + if !rendered.is_empty() { + let _ = write!(out, "\n{rendered}"); + } +} + fn append_map_footer( out: &mut String, artifact: ArtifactMode, diff --git a/src/search/display/mod.rs b/src/search/display/mod.rs index 7f966f7..c78fb86 100644 --- a/src/search/display/mod.rs +++ b/src/search/display/mod.rs @@ -717,9 +717,9 @@ pub(super) fn format_search_result_with_header( if result.total_found > 0 { let guidance = if has_context_next_targets { - "choose a confirmed context target above, or read raw hit evidence with `srcwalk show : -C 10`." + "choose a confirmed context target above, or read exact hit evidence with `srcwalk show : -C 10`." } else { - "read raw hit evidence with `srcwalk show : -C 10`; use `srcwalk context :` only after choosing a code hit." + "read exact hit evidence with `srcwalk show : -C 10`." }; append_next_action( &mut footer, diff --git a/tests/callees_detailed.rs b/tests/callees_detailed.rs index 2760229..03d321c 100644 --- a/tests/callees_detailed.rs +++ b/tests/callees_detailed.rs @@ -38,6 +38,29 @@ fn process() { fn send(data: &str) { println!("sending: {}", data); } + +fn nested_unresolved() { + outer( + inner(), + ); +} + +fn many_unresolved() { + call00(); + call01(); + call02(); + call03(); + call04(); + call05(); + call06(); + call07(); + call08(); + call09(); + call10(); + call11(); + call12(); + call13(); +} "#, ) .unwrap(); @@ -157,6 +180,74 @@ fn callees_default_lists_resolved() { ); } +#[test] +fn callees_default_shows_unresolved_call_site_evidence() { + setup_fixtures(); + let out = srcwalk() + .args(["trace", "callees", "process", "--scope"]) + .arg(fixture_dir()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("unresolved call sites (reason not classified)"), + "default output should label unresolved call-site evidence, got:\n{stdout}" + ); + assert!( + stdout.contains("L9") && stdout.contains("result.trim"), + "unresolved evidence should keep source line and call text, got:\n{stdout}" + ); + assert!( + !stdout.contains("(unresolved):"), + "default output should not collapse unresolved calls to a bare name list:\n{stdout}" + ); +} + +#[test] +fn callees_default_preserves_unrendered_unresolved_names() { + setup_fixtures(); + let out = srcwalk() + .args(["trace", "callees", "nested_unresolved", "--scope"]) + .arg(fixture_dir()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("unresolved call sites (reason not classified)"), + "default output should show unresolved call-site section, got:\n{stdout}" + ); + assert!( + stdout.contains("outer("), + "outer multiline call should have a call-site row, got:\n{stdout}" + ); + assert!( + stdout.contains("unresolved names without call-site rows: inner"), + "nested unresolved name without a rendered row must be preserved, got:\n{stdout}" + ); +} + +#[test] +fn callees_default_preserves_unresolved_names_after_row_cap() { + setup_fixtures(); + let out = srcwalk() + .args(["trace", "callees", "many_unresolved", "--scope"]) + .arg(fixture_dir()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("... 2 more unresolved call sites"), + "default output should report capped unresolved call-site rows, got:\n{stdout}" + ); + assert!( + stdout.contains("unresolved names without call-site rows: call12, call13"), + "unresolved names after the rendered row cap must be preserved, got:\n{stdout}" + ); +} + #[test] fn callees_python_detailed() { setup_fixtures(); diff --git a/tests/fixtures/callees/main.rs b/tests/fixtures/callees/main.rs index bd89e69..090478a 100644 --- a/tests/fixtures/callees/main.rs +++ b/tests/fixtures/callees/main.rs @@ -13,3 +13,26 @@ fn process() { fn send(data: &str) { println!("sending: {}", data); } + +fn nested_unresolved() { + outer( + inner(), + ); +} + +fn many_unresolved() { + call00(); + call01(); + call02(); + call03(); + call04(); + call05(); + call06(); + call07(); + call08(); + call09(); + call10(); + call11(); + call12(); + call13(); +} diff --git a/tests/impact_output.rs b/tests/impact_output.rs index f23328b..8bd90a0 100644 --- a/tests/impact_output.rs +++ b/tests/impact_output.rs @@ -97,7 +97,8 @@ fn impact_warns_for_broad_name_matched_symbols() { "expected broad symbol warning, got:\n{stdout}" ); assert!( - stdout.contains("51 direct name-matched call sites found; heuristic assess output capped"), + stdout.contains("51 direct name-matched call sites found inside") + && stdout.contains("heuristic assess output capped"), "expected capped footer, got:\n{stdout}" ); assert!( @@ -137,6 +138,16 @@ stdout: assert!( stdout.contains("0 direct name-matched call sites found"), "expected zero-call footer, got: +{stdout}" + ); + assert!( + stdout.contains("0 direct name-matched call sites found inside"), + "expected selected-scope count, got: +{stdout}" + ); + assert!( + stdout.contains("retry `srcwalk trace callers --scope `"), + "expected broader-scope verification hint, got: {stdout}" ); assert!( diff --git a/tests/intent_first_cli.rs b/tests/intent_first_cli.rs index 1825378..d591d49 100644 --- a/tests/intent_first_cli.rs +++ b/tests/intent_first_cli.rs @@ -674,6 +674,47 @@ func callStatus() { !stdout.contains("## Confirmed next context targets"), "unsupported context language must not suggest context targets:\n{stdout}" ); + assert!( + !stdout.contains("srcwalk context"), + "unsupported context language must not suggest any context command:\n{stdout}" + ); + + fs::write( + dir.join("OrderReturn.php"), + r#": -C 10`"), - "text discover footer should prefer exact raw reads before context guesses:\n{text_stdout}" + text_stdout.contains("read exact hit evidence with `srcwalk show : -C 10`"), + "text discover footer should prefer exact reads without context guesses:\n{text_stdout}" ); let doc_output = srcwalk() diff --git a/tests/map_output.rs b/tests/map_output.rs index c6146e0..81d85a5 100644 --- a/tests/map_output.rs +++ b/tests/map_output.rs @@ -64,6 +64,40 @@ fn overview_file_scope_error_is_actionable() { let _ = fs::remove_dir_all(&dir); } +#[test] +fn overview_empty_scope_is_actionable_without_narrowing() { + let dir = temp_repo("overview_empty_scope"); + + let out = srcwalk() + .arg("overview") + .arg("--scope") + .arg(&dir) + .arg("--symbols") + .output() + .unwrap(); + + assert!(out.status.success(), "empty overview should succeed"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("No overview entries found under"), + "expected empty overview diagnostic, got:\n{stdout}" + ); + assert!( + stdout.contains("may be empty, ignored, unsupported, or filtered"), + "expected honest cause caveat, got:\n{stdout}" + ); + assert!( + stdout.contains("srcwalk show "), + "expected exact-read fallback, got:\n{stdout}" + ); + assert!( + !stdout.contains("narrow with --scope"), + "empty scope should not suggest narrowing further:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + #[test] fn map_does_not_traverse_symlinked_directory_outside_scope() { let dir = temp_repo("map_symlink_escape"); diff --git a/tests/subcommand_aliases.rs b/tests/subcommand_aliases.rs index 3385265..3d6bc34 100644 --- a/tests/subcommand_aliases.rs +++ b/tests/subcommand_aliases.rs @@ -137,6 +137,23 @@ fn version_subcommand_is_canonical_version_surface() { ); } +#[test] +fn root_version_flags_match_version_subcommand() { + for flag in ["--version", "-V"] { + let output = srcwalk().arg(flag).output().unwrap(); + + assert!( + output.status.success(), + "{flag} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("srcwalk {}\n", env!("CARGO_PKG_VERSION")) + ); + } +} + #[test] fn version_help_exposes_check_flag() { let output = srcwalk().args(["version", "--help"]).output().unwrap(); From 225439aff67f7e5cbf628ebf878162ceed372026 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 09:39:54 +0700 Subject: [PATCH 2/9] Clarify unresolved callee overflow label --- src/commands/callees.rs | 2 +- tests/callees_detailed.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/callees.rs b/src/commands/callees.rs index 1529ea2..fef4bea 100644 --- a/src/commands/callees.rs +++ b/src/commands/callees.rs @@ -316,7 +316,7 @@ fn append_unresolved_call_site_evidence( if !unrendered_names.is_empty() { let _ = write!( out, - "\n unresolved names without call-site rows: {}", + "\n unresolved names without rendered call-site rows: {}", unrendered_names.join(", ") ); } diff --git a/tests/callees_detailed.rs b/tests/callees_detailed.rs index 03d321c..e7c8623 100644 --- a/tests/callees_detailed.rs +++ b/tests/callees_detailed.rs @@ -223,7 +223,7 @@ fn callees_default_preserves_unrendered_unresolved_names() { "outer multiline call should have a call-site row, got:\n{stdout}" ); assert!( - stdout.contains("unresolved names without call-site rows: inner"), + stdout.contains("unresolved names without rendered call-site rows: inner"), "nested unresolved name without a rendered row must be preserved, got:\n{stdout}" ); } @@ -243,7 +243,7 @@ fn callees_default_preserves_unresolved_names_after_row_cap() { "default output should report capped unresolved call-site rows, got:\n{stdout}" ); assert!( - stdout.contains("unresolved names without call-site rows: call12, call13"), + stdout.contains("unresolved names without rendered call-site rows: call12, call13"), "unresolved names after the rendered row cap must be preserved, got:\n{stdout}" ); } From c5a33b0070875e128f9a731b8bd93cd864c13292 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 15:01:13 +0700 Subject: [PATCH 3/9] feat(evidence): add bounded local structural links --- src/commands/flow.rs | 114 +++++ src/evidence/local_links.rs | 818 ++++++++++++++++++++++++++++++ src/evidence/local_links/tests.rs | 211 ++++++++ src/evidence/mod.rs | 1 + tests/flow_filter.rs | 193 +++++++ 5 files changed, 1337 insertions(+) create mode 100644 src/evidence/local_links.rs create mode 100644 src/evidence/local_links/tests.rs diff --git a/src/commands/flow.rs b/src/commands/flow.rs index 02f02fa..aa942d3 100644 --- a/src/commands/flow.rs +++ b/src/commands/flow.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::path::Path; use crate::cache::OutlineCache; @@ -232,6 +233,7 @@ fn append_context_neighborhood( } } + append_local_structural_links(out, source_path, content, lang, focus_range, scope, &sites); let names = if filter.is_some() { sites .iter() @@ -306,6 +308,118 @@ fn append_context_neighborhood( Ok(()) } +fn append_local_structural_links( + out: &mut String, + source_path: &Path, + content: &str, + lang: types::Lang, + focus_range: Option<(u32, u32)>, + scope: &Path, + sites: &[search::callees::CallSite], +) { + use std::fmt::Write as _; + + const MAX_ROWS: usize = 12; + let Some((start, end)) = focus_range else { + return; + }; + if sites.is_empty() { + return; + } + + let scope_id = format!("{}:{start}-{end}", format::display_path(source_path)); + let mut graphs = crate::evidence::local_links::collect_local_links_for_function_spans( + source_path, + content, + lang, + &[(&scope_id, start, end)], + ); + let Some(graph) = graphs.pop() else { + return; + }; + if graph.budget_exceeded() { + return; + } + + let visible_calls = sites + .iter() + .filter_map(|site| compact_call_site_identity(site, content)) + .collect::>(); + let mut selected = Vec::new(); + let mut seen = BTreeSet::new(); + + for argument_use in graph.links().iter().filter(|link| { + link.kind() == crate::evidence::local_links::LocalLinkKind::ArgumentUse + && visible_calls.contains(link.to().identity()) + }) { + let Some(mut chain) = graph.unique_predecessor_chain( + argument_use.from().identity(), + crate::evidence::local_links::DEFAULT_LOCAL_LINK_MAX_HOPS, + ) else { + continue; + }; + if chain.is_empty() { + continue; + } + chain.push(argument_use.clone()); + + for link in chain { + let anchor = link.anchor().display_relative_to(scope); + let key = ( + link.kind(), + link.from().identity().to_string(), + link.to().identity().to_string(), + anchor.clone(), + ); + if seen.insert(key) { + selected.push((link, anchor)); + } + } + } + + selected.sort_by(|(left, _), (right, _)| { + left.anchor() + .start_line() + .cmp(&right.anchor().start_line()) + .then(left.kind().cmp(&right.kind())) + .then(left.from().identity().cmp(right.from().identity())) + .then(left.to().identity().cmp(right.to().identity())) + }); + + if selected.is_empty() { + return; + } + + out.push_str("\n\n### Local structural links"); + let _ = write!(out, "\nconfidence: {}", selected[0].0.confidence()); + out.push_str("\ncaveat: same-function structural links only; not runtime dataflow"); + for (link, anchor) in selected.iter().take(MAX_ROWS) { + let _ = write!( + out, + "\n- {} -> {} [{}] {anchor}", + link.from().identity(), + link.to().identity(), + link.kind().as_str() + ); + } + if selected.len() > MAX_ROWS { + let _ = write!( + out, + "\n- ... {} more local structural links omitted", + selected.len() - MAX_ROWS + ); + } +} + +fn compact_call_site_identity(site: &search::callees::CallSite, content: &str) -> Option { + let text = site + .call_byte_range + .and_then(|(start, end)| content.get(start..end)) + .unwrap_or(&site.call_text); + let compact = text.split_whitespace().collect::>().join(" "); + (!compact.is_empty()).then_some(compact) +} + fn run_artifact_flow( target: &str, scope: &Path, diff --git a/src/evidence/local_links.rs b/src/evidence/local_links.rs new file mode 100644 index 0000000..843bece --- /dev/null +++ b/src/evidence/local_links.rs @@ -0,0 +1,818 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use tree_sitter::Node; + +use crate::evidence::Anchor; +use crate::lang::outline::outline_language; +use crate::search::callees::extract_call_sites; +use crate::types::Lang; + +pub(crate) const DEFAULT_LOCAL_LINK_MAX_HOPS: usize = 2; +pub(crate) const MAX_LOCAL_LINK_HOPS: usize = 3; +const MAX_LOCAL_LINKS: usize = 256; +const MAX_LOCAL_LINK_CHAINS: usize = 16; +const LOCAL_LINK_CONFIDENCE: &str = "local structural syntax"; +const TRAVERSAL_DEPTH_LIMIT: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct LocalSubject { + identity: String, +} + +impl LocalSubject { + pub(crate) fn new(identity: impl Into) -> Option { + let identity = identity.into().trim().to_string(); + (!identity.is_empty()).then_some(Self { identity }) + } + + pub(crate) fn identity(&self) -> &str { + &self.identity + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum LocalLinkKind { + AssignmentAlias, + FieldRead, + FieldWrite, + CallResult, + ArgumentUse, + ReturnValue, + Parameter, +} + +impl LocalLinkKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::AssignmentAlias => "assignment/alias", + Self::FieldRead => "field_read", + Self::FieldWrite => "field_write", + Self::CallResult => "call_result", + Self::ArgumentUse => "argument_use", + Self::ReturnValue => "return_value", + Self::Parameter => "parameter", + } + } +} + +impl LocalLinkKind { + fn is_binding_like(self) -> bool { + matches!( + self, + Self::AssignmentAlias | Self::FieldRead | Self::FieldWrite | Self::CallResult + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalLink { + kind: LocalLinkKind, + from: LocalSubject, + to: LocalSubject, + anchor: Anchor, + snippet: String, + confidence: &'static str, +} + +impl LocalLink { + fn new( + kind: LocalLinkKind, + from: LocalSubject, + to: LocalSubject, + anchor: Anchor, + snippet: impl Into, + ) -> Self { + Self { + kind, + from, + to, + anchor, + snippet: snippet.into(), + confidence: LOCAL_LINK_CONFIDENCE, + } + } + + pub(crate) fn kind(&self) -> LocalLinkKind { + self.kind + } + + pub(crate) fn from(&self) -> &LocalSubject { + &self.from + } + + pub(crate) fn to(&self) -> &LocalSubject { + &self.to + } + + pub(crate) fn anchor(&self) -> &Anchor { + &self.anchor + } + + pub(crate) fn snippet(&self) -> &str { + &self.snippet + } + + pub(crate) fn confidence(&self) -> &'static str { + self.confidence + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LocalLinkGraph { + links: Vec, + budget_exceeded: bool, +} + +impl LocalLinkGraph { + pub(crate) fn new() -> Self { + Self { + links: Vec::new(), + budget_exceeded: false, + } + } + + pub(crate) fn from_links(mut links: Vec) -> Self { + links.sort_by_key(local_link_sort_key); + links.dedup_by(|left, right| { + local_link_semantic_key(left) == local_link_semantic_key(right) + }); + let budget_exceeded = links.len() > MAX_LOCAL_LINKS; + if budget_exceeded { + links.truncate(MAX_LOCAL_LINKS); + } + Self { + links, + budget_exceeded, + } + } + + pub(crate) fn links(&self) -> &[LocalLink] { + &self.links + } + + pub(crate) fn budget_exceeded(&self) -> bool { + self.budget_exceeded + } + + pub(crate) fn unique_chain( + &self, + start: &str, + end: &str, + max_hops: usize, + ) -> Option> { + let start = start.trim(); + let end = end.trim(); + if start.is_empty() || end.is_empty() { + return None; + } + if start == end { + return Some(Vec::new()); + } + let max_hops = max_hops.clamp(1, MAX_LOCAL_LINK_HOPS); + if self.links.is_empty() + || self.budget_exceeded + || self.has_ambiguous_binding_target(start) + || self.has_ambiguous_binding_target(end) + { + return None; + } + + let mut chains: Vec> = Vec::new(); + let mut path = Vec::new(); + let mut seen = BTreeSet::new(); + seen.insert(start.to_string()); + self.collect_chains(start, end, max_hops, &mut seen, &mut path, &mut chains); + if chains.len() == 1 { + let chain = chains.pop().unwrap(); + if chain + .iter() + .any(|index| self.has_ambiguous_binding_target(self.links[*index].to.identity())) + { + return None; + } + Some( + chain + .into_iter() + .map(|index| self.links[index].clone()) + .collect(), + ) + } else { + None + } + } + + pub(crate) fn unique_predecessor_chain( + &self, + end: &str, + max_hops: usize, + ) -> Option> { + let end = end.trim(); + if end.is_empty() || self.budget_exceeded { + return None; + } + + let max_hops = max_hops.clamp(1, MAX_LOCAL_LINK_HOPS); + let mut current = end.to_string(); + let mut seen = BTreeSet::from([current.clone()]); + let mut hops = 0; + + while hops < max_hops { + let mut incoming = self + .links + .iter() + .filter(|link| link.to.identity() == current && link.kind.is_binding_like()); + let Some(link) = incoming.next() else { + break; + }; + if incoming.next().is_some() { + return None; + } + + let predecessor = link.from.identity().to_string(); + if !seen.insert(predecessor.clone()) { + return None; + } + current = predecessor; + hops += 1; + } + + self.unique_chain(¤t, end, max_hops) + } + + fn has_ambiguous_binding_target(&self, target: &str) -> bool { + let mut incoming = self + .links + .iter() + .filter(|link| link.to.identity() == target && link.kind.is_binding_like()); + let Some(first) = incoming.next() else { + return false; + }; + incoming.any(|link| { + link.from.identity() != first.from.identity() + || link.anchor.display() != first.anchor.display() + }) + } + + fn collect_chains( + &self, + current: &str, + end: &str, + max_hops: usize, + seen: &mut BTreeSet, + path: &mut Vec, + chains: &mut Vec>, + ) { + if chains.len() >= MAX_LOCAL_LINK_CHAINS || path.len() >= max_hops { + return; + } + + for (index, link) in self.links.iter().enumerate() { + if link.from.identity() != current { + continue; + } + let next = link.to.identity(); + if seen.contains(next) { + continue; + } + + path.push(index); + if next == end { + chains.push(path.clone()); + } else { + seen.insert(next.to_string()); + self.collect_chains(next, end, max_hops, seen, path, chains); + seen.remove(next); + } + path.pop(); + + if chains.len() >= MAX_LOCAL_LINK_CHAINS { + return; + } + } + } +} + +fn local_link_sort_key(link: &LocalLink) -> (usize, String, String, String, String) { + ( + link.kind as usize, + link.from.identity().to_string(), + link.to.identity().to_string(), + link.anchor().display(), + link.snippet().to_string(), + ) +} + +fn local_link_semantic_key(link: &LocalLink) -> (usize, String, String) { + ( + link.kind as usize, + link.from.identity().to_string(), + link.to.identity().to_string(), + ) +} + +#[cfg(test)] +pub(crate) fn collect_local_links_for_function( + path: &Path, + content: &str, + lang: Lang, + scope_label: &str, + start_line: u32, + end_line: u32, +) -> LocalLinkGraph { + let spans = [(scope_label, start_line, end_line)]; + collect_local_links_for_function_spans(path, content, lang, &spans) + .into_iter() + .next() + .unwrap_or_default() +} + +pub(crate) fn collect_local_links_for_function_spans( + path: &Path, + content: &str, + lang: Lang, + spans: &[(&str, u32, u32)], +) -> Vec { + if spans.is_empty() { + return Vec::new(); + } + + let empty_graphs = || vec![LocalLinkGraph::new(); spans.len()]; + let Some(ts_lang) = outline_language(lang) else { + return empty_graphs(); + }; + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&ts_lang).is_err() { + return empty_graphs(); + } + let Some(tree) = parser.parse(content, None) else { + return empty_graphs(); + }; + + let bytes = content.as_bytes(); + let root = tree.root_node(); + let call_sites = extract_call_sites(content, lang, None); + + spans + .iter() + .map(|(scope_label, start_line, end_line)| { + if *start_line == 0 || *end_line == 0 || *end_line < *start_line { + return LocalLinkGraph::new(); + } + + let mut links = Vec::new(); + collect_structure_links( + root, + bytes, + path, + scope_label, + *start_line, + *end_line, + &mut links, + 0, + ); + + for call_site in call_sites + .iter() + .filter(|call_site| (*start_line..=*end_line).contains(&call_site.line)) + { + collect_call_site_links(path, bytes, call_site, &mut links); + } + + LocalLinkGraph::from_links(links) + }) + .collect() +} + +fn collect_structure_links( + node: Node<'_>, + source: &[u8], + path: &Path, + scope_label: &str, + start_line: u32, + end_line: u32, + links: &mut Vec, + depth: usize, +) { + if depth > TRAVERSAL_DEPTH_LIMIT || !node_overlaps_span(node, start_line, end_line) { + return; + } + + if let Some(link) = assignment_link(node, source, path) { + links.push(link); + return; + } + + if let Some(link) = return_link(node, source, path) { + links.push(link); + return; + } + + if let Some(parameter_links) = parameter_links(node, source, path, scope_label) { + links.extend(parameter_links); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_structure_links( + child, + source, + path, + scope_label, + start_line, + end_line, + links, + depth + 1, + ); + } +} + +fn assignment_link(node: Node<'_>, source: &[u8], path: &Path) -> Option { + let parts = assignment_parts(node)?; + let snippet = compact_node_text(node, source); + let anchor = Anchor::line(path, line_start(node)); + let lhs_text = compact_node_text(parts.lhs, source); + let rhs = parts.rhs?; + let rhs_text = compact_node_text(rhs, source); + + let lhs_subject = subject_from_expression(parts.lhs, source)?; + if is_call_like(rhs.kind(), &rhs_text) { + return subject_from_text(&rhs_text).map(|from| { + LocalLink::new( + LocalLinkKind::CallResult, + from, + lhs_subject, + anchor, + snippet, + ) + }); + } + + let rhs_subject = subject_from_expression(rhs, source)?; + if is_field_like(&lhs_text) && !is_field_like(&rhs_text) { + return Some(LocalLink::new( + LocalLinkKind::FieldWrite, + rhs_subject, + lhs_subject, + anchor, + snippet, + )); + } + if is_field_like(&rhs_text) && !is_field_like(&lhs_text) { + return Some(LocalLink::new( + LocalLinkKind::FieldRead, + rhs_subject, + lhs_subject, + anchor, + snippet, + )); + } + if rhs_subject.identity() != lhs_subject.identity() { + return Some(LocalLink::new( + LocalLinkKind::AssignmentAlias, + rhs_subject, + lhs_subject, + anchor, + snippet, + )); + } + None +} + +fn return_link(node: Node<'_>, source: &[u8], path: &Path) -> Option { + if !node.kind().contains("return") { + return None; + } + let value = node + .named_child(0) + .or_else(|| first_identifier_like_descendant(node))?; + let from = subject_from_expression(value, source) + .or_else(|| subject_from_text(compact_node_text(value, source)))?; + let to = subject_from_text(format!("return@{}", line_start(node)))?; + Some(LocalLink::new( + LocalLinkKind::ReturnValue, + from, + to, + Anchor::line(path, line_start(node)), + compact_node_text(node, source), + )) +} + +fn collect_call_site_links( + path: &Path, + source: &[u8], + call_site: &crate::search::callees::CallSite, + links: &mut Vec, +) { + let call_text = compact_call_site_text(call_site, source); + let Some(call_subject) = subject_from_text(&call_text) else { + return; + }; + let anchor = Anchor::line(path, call_site.line); + for arg in &call_site.args { + if let Some(from) = subject_from_expression_text(arg) { + links.push(LocalLink::new( + LocalLinkKind::ArgumentUse, + from, + call_subject.clone(), + anchor.clone(), + call_text.clone(), + )); + } + } + if let Some(return_var) = call_site + .return_var + .as_deref() + .and_then(subject_from_expression_text) + { + links.push(LocalLink::new( + LocalLinkKind::CallResult, + call_subject.clone(), + return_var, + anchor.clone(), + call_text.clone(), + )); + } + if call_site.is_return { + if let Some(ret) = subject_from_text(format!("return@{}", call_site.line)) { + links.push(LocalLink::new( + LocalLinkKind::ReturnValue, + call_subject, + ret, + anchor, + call_text, + )); + } + } +} + +fn compact_call_site_text(call_site: &crate::search::callees::CallSite, source: &[u8]) -> String { + call_site + .call_byte_range + .and_then(|(start, end)| source.get(start..end)) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .map_or_else(|| compact_text(&call_site.call_text), compact_text) +} + +fn parameter_links( + node: Node<'_>, + source: &[u8], + path: &Path, + scope_label: &str, +) -> Option> { + let parameters = parameters_node(node)?; + let function_subject = subject_from_text(format!( + "function:{}@{}-{}", + scope_label, + line_start(node), + line_end(node) + ))?; + + let mut links = Vec::new(); + let mut cursor = parameters.walk(); + for parameter in parameters.named_children(&mut cursor) { + if is_punctuation_or_type_only(parameter.kind()) { + continue; + } + let Some(name) = parameter_name_node(parameter) else { + continue; + }; + let Some(subject) = subject_from_expression(name, source) + .or_else(|| subject_from_text(compact_node_text(name, source))) + else { + continue; + }; + links.push(LocalLink::new( + LocalLinkKind::Parameter, + subject, + function_subject.clone(), + Anchor::line(path, line_start(name)), + compact_node_text(parameter, source), + )); + } + + (!links.is_empty()).then_some(links) +} + +struct AssignmentParts<'tree> { + lhs: Node<'tree>, + rhs: Option>, +} + +fn assignment_parts(node: Node<'_>) -> Option> { + if is_assignment_node(node.kind()) { + let lhs = node + .child_by_field_name("left") + .or_else(|| node.child_by_field_name("pattern")) + .or_else(|| node.child_by_field_name("name")) + .or_else(|| node.child_by_field_name("declarator")) + .or_else(|| first_identifier_like_descendant(node))?; + let rhs = node + .child_by_field_name("right") + .or_else(|| node.child_by_field_name("value")); + return Some(AssignmentParts { lhs, rhs }); + } + + if is_declaration_wrapper(node.kind()) { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if let Some(parts) = assignment_parts(child) { + return Some(parts); + } + } + } + + None +} + +fn parameters_node(node: Node<'_>) -> Option> { + if let Some(parameters) = node.child_by_field_name("parameters") { + return Some(parameters); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if matches!( + child.kind(), + "parameters" | "formal_parameters" | "parameter_list" + ) { + return Some(child); + } + } + None +} + +fn parameter_name_node(parameter: Node<'_>) -> Option> { + parameter + .child_by_field_name("pattern") + .and_then(first_identifier_like_descendant) + .or_else(|| { + parameter + .child_by_field_name("name") + .and_then(first_identifier_like_descendant) + }) + .or_else(|| { + parameter + .child_by_field_name("declarator") + .and_then(first_identifier_like_descendant) + }) + .or_else(|| first_identifier_like_child(parameter)) +} + +fn first_identifier_like_descendant(node: Node<'_>) -> Option> { + if is_identifier_like(node.kind()) { + return Some(node); + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if let Some(identifier) = first_identifier_like_descendant(child) { + return Some(identifier); + } + } + None +} + +fn first_identifier_like_child(node: Node<'_>) -> Option> { + let mut cursor = node.walk(); + let found = node + .named_children(&mut cursor) + .find(|child| is_identifier_like(child.kind())); + found +} + +fn is_identifier_like(kind: &str) -> bool { + matches!( + kind, + "identifier" + | "property_identifier" + | "field_identifier" + | "simple_identifier" + | "constant" + | "self_parameter" + | "shorthand_property_identifier" + | "shorthand_property_identifier_pattern" + ) +} + +fn is_punctuation_or_type_only(kind: &str) -> bool { + matches!(kind, "," | ":" | "type_identifier" | "primitive_type") +} + +fn is_assignment_node(kind: &str) -> bool { + matches!( + kind, + "assignment" + | "let_declaration" + | "assignment_expression" + | "assignment_statement" + | "augmented_assignment" + | "short_var_declaration" + | "var_spec" + | "variable_declarator" + | "init_declarator" + | "variable_declaration" + | "local_variable_declaration" + ) +} + +fn is_declaration_wrapper(kind: &str) -> bool { + matches!( + kind, + "lexical_declaration" | "const_declaration" | "var_declaration" | "declaration" + ) +} + +fn is_call_like(kind: &str, text: &str) -> bool { + kind.contains("call") + || kind.contains("invocation") + || kind.contains("creation") + || text.contains('(') +} + +fn is_field_like(text: &str) -> bool { + text.contains('.') +} + +fn subject_from_text(text: impl Into) -> Option { + LocalSubject::new(text) +} + +fn subject_from_expression(node: Node<'_>, source: &[u8]) -> Option { + subject_from_expression_text(&compact_node_text(node, source)) +} + +fn subject_from_expression_text(text: &str) -> Option { + let text = compact_text(text); + let normalized = normalize_path_like_expression(&text)?; + LocalSubject::new(normalized) +} + +fn compact_node_text(node: Node<'_>, source: &[u8]) -> String { + node.utf8_text(source).map(compact_text).unwrap_or_default() +} + +fn compact_text(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn normalize_path_like_expression(expression: &str) -> Option { + let mut value = expression.trim(); + while let Some(stripped) = value.strip_prefix('&').or_else(|| value.strip_prefix('*')) { + value = stripped.trim_start(); + } + let normalized = value + .replace(" .", ".") + .replace(". ", ".") + .replace("?.", ".") + .replace(" ->", "->") + .replace("-> ", "->") + .replace("->", ".") + .replace(" ::", "::") + .replace(":: ", "::") + .replace("::", "."); + if normalized.is_empty() + || normalized.contains('(') + || normalized.contains(')') + || normalized.contains('[') + || normalized.contains(']') + || normalized.contains('{') + || normalized.contains('}') + || normalized.contains(',') + || normalized.contains(' ') + { + return None; + } + let mut parts = normalized.split('.'); + let root = parts.next()?; + if !is_identifier_segment(root) + || root.chars().next().is_some_and(|ch| ch.is_ascii_digit()) + || parts.any(|part| !is_identifier_segment(part)) + { + return None; + } + Some(normalized) +} + +fn is_identifier_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') +} + +fn node_overlaps_span(node: Node<'_>, start_line: u32, end_line: u32) -> bool { + let node_start = node.start_position().row as u32 + 1; + let node_end = node.end_position().row as u32 + 1; + node_start <= end_line && node_end >= start_line +} + +fn line_start(node: Node<'_>) -> u32 { + node.start_position().row as u32 + 1 +} + +fn line_end(node: Node<'_>) -> u32 { + node.end_position().row as u32 + 1 +} + +#[cfg(test)] +mod tests; diff --git a/src/evidence/local_links/tests.rs b/src/evidence/local_links/tests.rs new file mode 100644 index 0000000..f1ed5b1 --- /dev/null +++ b/src/evidence/local_links/tests.rs @@ -0,0 +1,211 @@ +use std::path::Path; + +use super::*; +use crate::evidence::Anchor; +use crate::types::Lang; + +fn subject(value: &str) -> LocalSubject { + LocalSubject::new(value).unwrap() +} + +fn link(kind: LocalLinkKind, from: &str, to: &str, line: u32) -> LocalLink { + LocalLink::new( + kind, + subject(from), + subject(to), + Anchor::line(Path::new("src/lib.rs"), line), + format!("{from}->{to}"), + ) +} + +#[test] +fn graph_sorts_and_dedups_links_deterministically() { + let graph = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::FieldRead, "b", "c", 3), + link(LocalLinkKind::AssignmentAlias, "a", "b", 2), + link(LocalLinkKind::AssignmentAlias, "a", "b", 2), + ]); + + assert_eq!(graph.links().len(), 2); + assert_eq!(graph.links()[0].from().identity(), "a"); + assert_eq!(graph.links()[1].from().identity(), "b"); +} + +#[test] +fn graph_dedups_semantic_edges_with_different_snippets() { + let graph = LocalLinkGraph::from_links(vec![ + LocalLink::new( + LocalLinkKind::CallResult, + subject("GetToken(req)"), + subject("token"), + Anchor::line(Path::new("src/lib.rs"), 10), + "let token = GetToken(req);", + ), + LocalLink::new( + LocalLinkKind::CallResult, + subject("GetToken(req)"), + subject("token"), + Anchor::line(Path::new("src/lib.rs"), 11), + "GetToken(req)", + ), + ]); + + assert_eq!(graph.links().len(), 1); + assert_eq!( + graph + .unique_chain("GetToken(req)", "token", 1) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn graph_dedups_multiline_call_result_edges_from_call_sites() { + let rust = r#" +fn demo(req: Req, suffix: &str) { + let path = build_path( + req.path, + suffix + ); + OpenFile(path); +} +"#; + let graph = + collect_local_links_for_function(Path::new("src/lib.rs"), rust, Lang::Rust, "demo", 1, 7); + let call_result_links: Vec<_> = graph + .links() + .iter() + .filter(|link| link.kind() == LocalLinkKind::CallResult && link.to().identity() == "path") + .collect(); + + assert_eq!(call_result_links.len(), 1); + assert_eq!( + call_result_links[0].from().identity(), + "build_path( req.path, suffix )" + ); + assert_eq!( + graph + .unique_chain("build_path( req.path, suffix )", "path", 1) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn graph_finds_unique_chain_and_abstains_on_ambiguity() { + let unique = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "path", "alias", 2), + link(LocalLinkKind::AssignmentAlias, "req.path", "path", 1), + ]); + let chain = unique + .unique_chain("req.path", "alias", DEFAULT_LOCAL_LINK_MAX_HOPS) + .unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0].kind(), LocalLinkKind::AssignmentAlias); + + let ambiguous = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "a", "b", 1), + link(LocalLinkKind::AssignmentAlias, "b", "c", 2), + link(LocalLinkKind::AssignmentAlias, "a", "c", 3), + ]); + assert!(ambiguous.unique_chain("a", "c", 2).is_none()); + let ambiguous_write = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "req.safe", "path", 1), + link(LocalLinkKind::AssignmentAlias, "req.user", "path", 2), + link(LocalLinkKind::AssignmentAlias, "path", "alias", 3), + ]); + assert!(ambiguous_write + .unique_chain("req.safe", "path", 1) + .is_none()); + assert!(ambiguous_write + .unique_chain("req.safe", "alias", 2) + .is_none()); + assert!(ambiguous_write.unique_chain("path", "alias", 1).is_none()); +} + +#[test] +fn graph_abstains_on_cycles_and_accepts_zero_hop_identity() { + let graph = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "a", "b", 1), + link(LocalLinkKind::AssignmentAlias, "b", "a", 2), + link(LocalLinkKind::AssignmentAlias, "b", "c", 3), + ]); + + assert_eq!(graph.unique_chain("a", "c", 3).unwrap().len(), 2); + assert_eq!(graph.unique_chain("a", "a", 2).unwrap().len(), 0); +} + +#[test] +fn graph_abstains_when_link_budget_is_exceeded() { + let mut links = vec![link(LocalLinkKind::AssignmentAlias, "start", "end", 1)]; + for index in 0..MAX_LOCAL_LINKS { + links.push(link( + LocalLinkKind::AssignmentAlias, + &format!("extra{index}"), + &format!("other{index}"), + index as u32 + 2, + )); + } + let graph = LocalLinkGraph::from_links(links); + + assert!(graph.budget_exceeded()); + assert!(graph.unique_chain("start", "end", 1).is_none()); +} + +#[test] +fn extracts_rust_and_javascript_assignment_chains() { + let rust = r#" +fn demo(req: Req) { +let path = req.path; +let alias = path; +ValidatePath(path); +OpenFile(alias); +} +"#; + let rust_graph = + collect_local_links_for_function(Path::new("src/lib.rs"), rust, Lang::Rust, "demo", 1, 7); + assert!(rust_graph.unique_chain("req.path", "alias", 2).is_some()); + + let js = r#" +function demo(req) { + const path = req.path; + const alias = path; + ValidatePath(path); + OpenFile(alias); +} +"#; + let js_graph = collect_local_links_for_function( + Path::new("src/lib.js"), + js, + Lang::JavaScript, + "demo", + 1, + 6, + ); + assert!(js_graph.unique_chain("req.path", "alias", 2).is_some()); +} + +#[test] +fn predecessor_chain_is_bounded_and_abstains_on_ambiguity() { + let unique = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "req.path", "path", 1), + link(LocalLinkKind::AssignmentAlias, "path", "alias", 2), + ]); + let chain = unique.unique_predecessor_chain("alias", 2).unwrap(); + assert_eq!( + chain + .iter() + .map(|link| (link.from().identity(), link.to().identity())) + .collect::>(), + vec![("req.path", "path"), ("path", "alias")] + ); + + let ambiguous = LocalLinkGraph::from_links(vec![ + link(LocalLinkKind::AssignmentAlias, "req.safe", "path", 1), + link(LocalLinkKind::AssignmentAlias, "req.user", "path", 2), + link(LocalLinkKind::AssignmentAlias, "path", "alias", 3), + ]); + assert!(ambiguous.unique_predecessor_chain("alias", 2).is_none()); +} diff --git a/src/evidence/mod.rs b/src/evidence/mod.rs index 717e91f..2ffa4d4 100644 --- a/src/evidence/mod.rs +++ b/src/evidence/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod anchor; pub(crate) mod atom; pub(crate) mod confidence; +pub(crate) mod local_links; pub(crate) mod next_action; pub(crate) use anchor::Anchor; diff --git a/tests/flow_filter.rs b/tests/flow_filter.rs index 40f04bc..410cf01 100644 --- a/tests/flow_filter.rs +++ b/tests/flow_filter.rs @@ -828,3 +828,196 @@ struct ngx_http_core_loc_conf_s { "context should resolve the named struct body range, got:\n{stdout}" ); } + +#[test] +fn context_renders_bounded_rust_local_structural_links() { + let dir = temp_dir("context_rust_local_links"); + let file = dir.join("lib.rs"); + fs::write( + &file, + r#" +struct Request { path: String } + +fn open(_: String) {} + +fn handle(req: Request) { + let path = req.path; + let alias = path; + open(alias); +} +"#, + ) + .unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + assert!( + stdout.contains("### Local structural links"), + "expected local structural link section:\n{stdout}" + ); + assert!(stdout.contains("req.path -> path [field_read]")); + assert!( + stdout.contains("path -> alias [assignment/alias]"), + "expected alias predecessor link:\n{stdout}" + ); + assert!(stdout.contains("alias -> open(alias) [argument_use]")); + assert!(stdout.contains("confidence: local structural syntax")); + assert!(stdout.contains("not runtime dataflow")); + assert_no_def_use_verdict_words(&stdout); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_renders_javascript_and_multiline_call_result_links() { + let dir = temp_dir("context_js_local_links"); + let js_file = dir.join("lib.js"); + fs::write( + &js_file, + r#" +function handle(req) { + const path = req.path; + const alias = path; + open(alias); +} +"#, + ) + .unwrap(); + + let js_stdout = context_output(&dir, &js_file, "handle"); + assert!(js_stdout.contains("req.path -> path [field_read]")); + assert!(js_stdout.contains("alias -> open(alias) [argument_use]")); + + let rust_file = dir.join("call_result.rs"); + fs::write( + &rust_file, + r#" +fn load_config(_: &str) -> String { String::new() } +fn open(_: String) {} + +fn handle(path: &str) { + let config = load_config( + path, + ); + open(config); +} +"#, + ) + .unwrap(); + + let rust_stdout = context_output(&dir, &rust_file, "handle"); + assert!( + rust_stdout.contains("-> config [call_result]"), + "multiline call result identity must connect to its binding:\n{rust_stdout}" + ); + assert!(rust_stdout.contains("config -> open(config) [argument_use]")); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_abstains_on_ambiguous_local_predecessors() { + let dir = temp_dir("context_ambiguous_local_links"); + let file = dir.join("lib.rs"); + fs::write( + &file, + r#" +struct Request { safe: String, user: String } +fn open(_: String) {} + +fn handle(req: Request) { + let path = req.safe; + let path = req.user; + open(path); +} +"#, + ) + .unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + assert!( + !stdout.contains("### Local structural links"), + "ambiguous predecessor must abstain instead of choosing a chain:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_caps_local_structural_link_rows_with_omitted_count() { + let dir = temp_dir("context_capped_local_links"); + let file = dir.join("lib.rs"); + let mut source = String::from("struct Request { value: i32 }\n"); + for index in 0..13 { + source.push_str(&format!("fn sink{index}(_: i32) {{}}\n")); + } + source.push_str("fn handle(req: Request) {\n"); + for index in 0..13 { + source.push_str(&format!( + " let value{index} = req.value;\n sink{index}(value{index});\n" + )); + } + source.push_str("}\n"); + fs::write(&file, source).unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + assert!(stdout.contains("### Local structural links")); + assert!( + stdout.contains("more local structural links omitted"), + "expected deterministic omitted count after row cap:\n{stdout}" + ); + let rendered_rows = stdout + .lines() + .filter(|line| line.starts_with("- ") && line.contains("] ") && !line.contains("omitted")) + .count(); + assert_eq!( + rendered_rows, 12, + "local-link rows must respect cap:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_filter_limits_local_links_to_visible_calls() { + let dir = temp_dir("context_filtered_local_links"); + let file = dir.join("lib.rs"); + fs::write( + &file, + r#" +struct Request { first: i32, second: i32 } +fn one(_: i32) {} +fn two(_: i32) {} + +fn handle(req: Request) { + let first = req.first; + one(first); + let second = req.second; + two(second); +} +"#, + ) + .unwrap(); + + let target = format!("{}:handle", file.display()); + let output = srcwalk() + .args(["context", &target, "--filter", "callee:two", "--scope"]) + .arg(&dir) + .arg("--no-budget") + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let local_section = stdout + .split("### Local structural links") + .nth(1) + .and_then(|rest| rest.split("### Resolved local callees").next()) + .expect("filtered call should retain one local-link section"); + assert!(local_section.contains("req.second -> second [field_read]")); + assert!(local_section.contains("second -> two(second) [argument_use]")); + assert!( + !local_section.contains("req.first") && !local_section.contains("one(first)"), + "filtered local-link section leaked hidden call evidence:\n{local_section}" + ); + + let _ = fs::remove_dir_all(&dir); +} From 929c576eec44a31237816302cf16d488f9ab7f34 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 15:51:56 +0700 Subject: [PATCH 4/9] feat(evidence): add bounded direct-call links Add conservative same/related-file direct-call evidence and positional argument mappings to context and detailed callees output. Abstain on qualified, ambiguous, recursive, or unreliable cases; keep call-derived local links within visible rows; and cover caps, filters, and false-positive regressions. --- src/commands/call_format.rs | 77 +++ src/commands/callees.rs | 39 +- src/commands/flow.rs | 94 +++- src/evidence/direct_call.rs | 811 ++++++++++++++++++++++++++++++ src/evidence/direct_call/tests.rs | 277 ++++++++++ src/evidence/local_links.rs | 9 +- src/evidence/local_links/tests.rs | 17 + src/evidence/mod.rs | 1 + tests/callees_detailed.rs | 71 +++ tests/flow_filter.rs | 109 ++++ 10 files changed, 1486 insertions(+), 19 deletions(-) create mode 100644 src/evidence/direct_call.rs create mode 100644 src/evidence/direct_call/tests.rs diff --git a/src/commands/call_format.rs b/src/commands/call_format.rs index 8ccde94..7262253 100644 --- a/src/commands/call_format.rs +++ b/src/commands/call_format.rs @@ -1,3 +1,7 @@ +use std::fmt::Write as _; +use std::path::Path; + +use crate::evidence::direct_call::{ArgParamMapping, DirectCallEvidenceEdge, DirectCallUnknown}; use crate::search; pub(crate) fn format_call_site(site: &search::callees::CallSite) -> String { @@ -48,3 +52,76 @@ fn compact_arg(arg: &str) -> String { .collect::(); format!("{head} … {tail}") } + +pub(crate) fn format_direct_call_edge( + edge: &DirectCallEvidenceEdge, + scope: &Path, + indent: usize, +) -> String { + let pad = " ".repeat(indent); + let target = edge.target_anchor().display_relative_to(scope); + let mut out = format!( + "{pad}-> [fn] {} {target}\n{pad} confidence: {}", + edge.target_name(), + edge.confidence().as_str() + ); + for mapping in edge.arg_param_mappings() { + let _ = write!( + out, + "\n{pad} arg{} `{}` -> param{} `{}`", + mapping.arg_index(), + mapping.arg_display(), + mapping.param_index(), + mapping.param_name() + ); + } + if !edge.arg_param_mappings().is_empty() { + let _ = write!( + out, + "\n{pad} mapping confidence: {}", + ArgParamMapping::confidence() + ); + } + if let Some(reason) = edge.mapping_unknown() { + let _ = write!( + out, + "\n{pad} arg→param mapping: unknown ({})", + reason.as_str() + ); + } + if edge.omitted_arg_param_mappings() > 0 { + let _ = write!( + out, + "\n{pad} ... {} arg→param mappings omitted", + edge.omitted_arg_param_mappings() + ); + } + out +} + +pub(crate) fn format_direct_call_unknown( + unknown: &DirectCallUnknown, + scope: &Path, + indent: usize, +) -> String { + let pad = " ".repeat(indent); + let mut out = format!( + "{pad}-> direct target: unknown ({})", + unknown.reason().as_str() + ); + for candidate in unknown.candidates() { + let _ = write!( + out, + "\n{pad} candidate: {}", + candidate.display_relative_to(scope) + ); + } + if unknown.omitted_candidates() > 0 { + let _ = write!( + out, + "\n{pad} ... {} candidates omitted", + unknown.omitted_candidates() + ); + } + out +} diff --git a/src/commands/callees.rs b/src/commands/callees.rs index fef4bea..ded2d2a 100644 --- a/src/commands/callees.rs +++ b/src/commands/callees.rs @@ -7,7 +7,9 @@ use crate::error::SrcwalkError; use crate::evidence::{render_next_actions, NextAction}; use crate::{budget, format, index, lang, search, types}; -use crate::commands::call_format::format_call_site; +use crate::commands::call_format::{ + format_call_site, format_direct_call_edge, format_direct_call_unknown, +}; use crate::commands::find::symbol_or_file_suggestion; /// Show what a symbol calls (forward call graph). @@ -92,6 +94,15 @@ pub(crate) fn run_callees_with_artifact( }; let total_sites = sites.len(); let sites = search::callees::filter_call_sites(sites, filter)?; + let direct_calls = (!artifact.enabled()).then(|| { + crate::evidence::direct_call::build_direct_call_evidence_index( + &def_match.path, + &content, + lang, + def_match.def_range, + &sites, + ) + }); if sites.is_empty() { let suffix = filter.map_or(String::new(), |f| format!(" matching `{f}`")); return Ok(format!( @@ -111,7 +122,33 @@ pub(crate) fn run_callees_with_artifact( } else { let _ = write!(out, "\n{}", format_call_site(s)); } + if let Some(index) = &direct_calls { + if let Some(edge) = index.edge_for_site(s, &content) { + let _ = write!(out, "\n{}", format_direct_call_edge(edge, scope, 2)); + } else if let Some(unknown) = index.unknown_for_site(s, &content) { + let _ = write!(out, "\n{}", format_direct_call_unknown(unknown, scope, 2)); + } + } } + if let Some(index) = &direct_calls { + let omitted = index + .omitted_edges() + .saturating_add(index.omitted_unknowns()); + if omitted > 0 { + let _ = write!( + out, + "\n\n> Note: {omitted} direct-call evidence rows omitted." + ); + } + if index.omitted_related_files() > 0 { + let _ = write!( + out, + "\n> Note: {} related files omitted from direct-call resolution.", + index.omitted_related_files() + ); + } + } + if filter.is_some() { let _ = write!( out, diff --git a/src/commands/flow.rs b/src/commands/flow.rs index aa942d3..6907547 100644 --- a/src/commands/flow.rs +++ b/src/commands/flow.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::path::Path; use crate::cache::OutlineCache; -use crate::commands::call_format::format_call_site; +use crate::commands::call_format::{format_call_site, format_direct_call_edge}; use crate::commands::context::{apply_optional_budget, ArtifactMode}; use crate::commands::decision_flow::resolve_decision_flow_target; use crate::commands::find::symbol_or_file_suggestion; @@ -217,6 +217,14 @@ fn append_context_neighborhood( let sites = search::callees::extract_call_sites(content, lang, focus_range); let total_sites = sites.len(); let sites = search::callees::filter_call_sites(sites, filter)?; + let visible_sites = sites.iter().take(12).cloned().collect::>(); + let direct_calls = crate::evidence::direct_call::build_direct_call_evidence_index( + source_path, + content, + lang, + focus_range, + &visible_sites, + ); if let Some(filter) = filter { let _ = writeln!(out, "\n### Callees (ordered, filtered {filter})"); } else { @@ -225,7 +233,7 @@ fn append_context_neighborhood( if sites.is_empty() { out.push_str("\n- none"); } else { - for site in sites.iter().take(12) { + for site in &visible_sites { let _ = write!(out, "\n- {}", format_call_site(site)); } if sites.len() > 12 { @@ -233,7 +241,16 @@ fn append_context_neighborhood( } } - append_local_structural_links(out, source_path, content, lang, focus_range, scope, &sites); + append_local_structural_links( + out, + source_path, + content, + lang, + focus_range, + scope, + &visible_sites, + ); + append_direct_call_evidence(out, scope, &direct_calls); let names = if filter.is_some() { sites .iter() @@ -343,14 +360,19 @@ fn append_local_structural_links( let visible_calls = sites .iter() - .filter_map(|site| compact_call_site_identity(site, content)) + .filter_map(|site| { + crate::evidence::direct_call::call_site_display(site, content) + .map(|display| (site.line, display)) + }) .collect::>(); let mut selected = Vec::new(); let mut seen = BTreeSet::new(); for argument_use in graph.links().iter().filter(|link| { link.kind() == crate::evidence::local_links::LocalLinkKind::ArgumentUse - && visible_calls.contains(link.to().identity()) + && visible_calls.iter().any(|(line, display)| { + *line == link.anchor().start_line() && display == link.to().identity() + }) }) { let Some(mut chain) = graph.unique_predecessor_chain( argument_use.from().identity(), @@ -362,6 +384,24 @@ fn append_local_structural_links( continue; } chain.push(argument_use.clone()); + if chain.iter().any(|link| { + let call_identity = match link.kind() { + crate::evidence::local_links::LocalLinkKind::CallResult => { + Some(link.from().identity()) + } + crate::evidence::local_links::LocalLinkKind::ArgumentUse => { + Some(link.to().identity()) + } + _ => None, + }; + call_identity.is_some_and(|identity| { + !visible_calls.iter().any(|(line, display)| { + *line == link.anchor().start_line() && display == identity + }) + }) + }) { + continue; + } for link in chain { let anchor = link.anchor().display_relative_to(scope); @@ -411,13 +451,43 @@ fn append_local_structural_links( } } -fn compact_call_site_identity(site: &search::callees::CallSite, content: &str) -> Option { - let text = site - .call_byte_range - .and_then(|(start, end)| content.get(start..end)) - .unwrap_or(&site.call_text); - let compact = text.split_whitespace().collect::>().join(" "); - (!compact.is_empty()).then_some(compact) +fn append_direct_call_evidence( + out: &mut String, + scope: &Path, + index: &crate::evidence::direct_call::DirectCallEvidenceIndex, +) { + use std::fmt::Write as _; + + const MAX_EDGES: usize = 12; + if index.edges().is_empty() { + return; + } + + out.push_str("\n\n### Direct-call evidence"); + for edge in index.edges().iter().take(MAX_EDGES) { + let _ = write!( + out, + "\n- L{} {}\n{}", + edge.call_anchor().start_line(), + edge.call_display(), + format_direct_call_edge(edge, scope, 2) + ); + } + let omitted = index + .edges() + .len() + .saturating_sub(MAX_EDGES) + .saturating_add(index.omitted_edges()); + if omitted > 0 { + let _ = write!(out, "\n- ... {omitted} direct-call edges omitted"); + } + if index.omitted_related_files() > 0 { + let _ = write!( + out, + "\n- ... {} related files omitted from direct-call resolution", + index.omitted_related_files() + ); + } } fn run_artifact_flow( diff --git a/src/evidence/direct_call.rs b/src/evidence/direct_call.rs new file mode 100644 index 0000000..ca394d4 --- /dev/null +++ b/src/evidence/direct_call.rs @@ -0,0 +1,811 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use crate::evidence::Anchor; +use crate::lang::outline::get_outline_entries; +use crate::search::callees::CallSite; +use crate::types::{FileType, Lang, OutlineEntry, OutlineKind}; + +const MAX_DIRECT_CALL_EDGES: usize = 32; +const MAX_DIRECT_CALL_UNKNOWNS: usize = 16; +const MAX_RELATED_FILES: usize = 20; +const MAX_UNKNOWN_CANDIDATES: usize = 5; +const MAX_ARG_PARAM_MAPPINGS_PER_EDGE: usize = 8; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct DirectCallEvidenceIndex { + edges: Vec, + unknowns: Vec, + omitted_edges: usize, + omitted_unknowns: usize, + omitted_related_files: usize, +} + +impl DirectCallEvidenceIndex { + pub(crate) fn edges(&self) -> &[DirectCallEvidenceEdge] { + &self.edges + } + + #[cfg(test)] + pub(crate) fn unknowns(&self) -> &[DirectCallUnknown] { + &self.unknowns + } + + pub(crate) const fn omitted_edges(&self) -> usize { + self.omitted_edges + } + + pub(crate) const fn omitted_unknowns(&self) -> usize { + self.omitted_unknowns + } + + pub(crate) const fn omitted_related_files(&self) -> usize { + self.omitted_related_files + } + + pub(crate) fn edge_for_site( + &self, + site: &CallSite, + content: &str, + ) -> Option<&DirectCallEvidenceEdge> { + let display = call_site_display(site, content)?; + self.edges.iter().find(|edge| { + edge.call_anchor().start_line() == site.line + && edge.call_callee() == site.callee + && edge.call_display() == display + }) + } + + pub(crate) fn unknown_for_site( + &self, + site: &CallSite, + content: &str, + ) -> Option<&DirectCallUnknown> { + let display = call_site_display(site, content)?; + self.unknowns.iter().find(|unknown| { + unknown.call_anchor().start_line() == site.line + && unknown.call_callee() == site.callee + && unknown.call_display() == display + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DirectCallEvidenceEdge { + call_anchor: Anchor, + call_callee: String, + call_display: String, + target_name: String, + target_anchor: Anchor, + confidence: DirectCallResolutionConfidence, + arg_param_mappings: Vec, + omitted_arg_param_mappings: usize, + mapping_unknown: Option, +} + +impl DirectCallEvidenceEdge { + pub(crate) const fn call_anchor(&self) -> &Anchor { + &self.call_anchor + } + + pub(crate) fn call_callee(&self) -> &str { + &self.call_callee + } + + pub(crate) fn call_display(&self) -> &str { + &self.call_display + } + + pub(crate) fn target_name(&self) -> &str { + &self.target_name + } + + pub(crate) const fn target_anchor(&self) -> &Anchor { + &self.target_anchor + } + + pub(crate) const fn confidence(&self) -> DirectCallResolutionConfidence { + self.confidence + } + + pub(crate) fn arg_param_mappings(&self) -> &[ArgParamMapping] { + &self.arg_param_mappings + } + + pub(crate) const fn omitted_arg_param_mappings(&self) -> usize { + self.omitted_arg_param_mappings + } + + pub(crate) const fn mapping_unknown(&self) -> Option { + self.mapping_unknown + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ArgParamMapping { + arg_index: usize, + arg_display: String, + param_index: usize, + param_name: String, +} + +impl ArgParamMapping { + pub(crate) const fn arg_index(&self) -> usize { + self.arg_index + } + + pub(crate) fn arg_display(&self) -> &str { + &self.arg_display + } + + pub(crate) const fn param_index(&self) -> usize { + self.param_index + } + + pub(crate) fn param_name(&self) -> &str { + &self.param_name + } + + pub(crate) const fn confidence() -> &'static str { + "syntactic positional arg/param" + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ArgParamMappingUnknownReason { + UnreliableSignature, + NonPositionalArguments, + ArityMismatch, +} + +impl ArgParamMappingUnknownReason { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::UnreliableSignature => "callee signature is not structurally reliable", + Self::NonPositionalArguments => { + "named, spread, splat, or otherwise non-positional arguments" + } + Self::ArityMismatch => "argument and parameter counts differ", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectCallResolutionConfidence { + SameFileStructural, + ExplicitRelatedFileStructural, +} + +impl DirectCallResolutionConfidence { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::SameFileStructural => "same-file structural direct call", + Self::ExplicitRelatedFileStructural => "explicit related-file structural direct call", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DirectCallUnknown { + call_anchor: Anchor, + call_callee: String, + call_display: String, + reason: DirectCallUnknownReason, + candidates: Vec, + omitted_candidates: usize, +} + +impl DirectCallUnknown { + pub(crate) const fn call_anchor(&self) -> &Anchor { + &self.call_anchor + } + + pub(crate) fn call_callee(&self) -> &str { + &self.call_callee + } + + pub(crate) fn call_display(&self) -> &str { + &self.call_display + } + + pub(crate) const fn reason(&self) -> DirectCallUnknownReason { + self.reason + } + + pub(crate) fn candidates(&self) -> &[Anchor] { + &self.candidates + } + + pub(crate) const fn omitted_candidates(&self) -> usize { + self.omitted_candidates + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum DirectCallUnknownReason { + AmbiguousTarget, + SelfRecursiveCall, +} + +impl DirectCallUnknownReason { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::AmbiguousTarget => "ambiguous structural target", + Self::SelfRecursiveCall => "self-recursive call omitted from one-hop evidence", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DirectCallTarget { + name: String, + path: PathBuf, + start_line: u32, + end_line: u32, + signature: Option, + confidence: DirectCallResolutionConfidence, +} + +pub(crate) fn build_direct_call_evidence_index( + source_path: &Path, + content: &str, + lang: Lang, + caller_range: Option<(u32, u32)>, + sites: &[CallSite], +) -> DirectCallEvidenceIndex { + if sites.is_empty() { + return DirectCallEvidenceIndex::default(); + } + + let names = sites + .iter() + .map(|site| site.callee.as_str()) + .collect::>(); + let same_file = collect_targets( + source_path, + &get_outline_entries(content, lang), + &names, + DirectCallResolutionConfidence::SameFileStructural, + ); + + let related_paths = + crate::read::imports::resolve_all_related_files_with_content(source_path, content); + let omitted_related_files = related_paths.len().saturating_sub(MAX_RELATED_FILES); + let mut related = Vec::new(); + for related_path in related_paths.into_iter().take(MAX_RELATED_FILES) { + let Ok(related_content) = std::fs::read_to_string(&related_path) else { + continue; + }; + let FileType::Code(related_lang) = crate::lang::detect_file_type(&related_path) else { + continue; + }; + related.extend(collect_targets( + &related_path, + &get_outline_entries(&related_content, related_lang), + &names, + DirectCallResolutionConfidence::ExplicitRelatedFileStructural, + )); + } + + let mut edges = Vec::new(); + let mut unknowns = Vec::new(); + for site in sites { + let Some(call_display) = call_site_display(site, content) else { + continue; + }; + if site + .call_prefix + .as_deref() + .is_some_and(|prefix| prefix.trim() != site.callee) + { + continue; + } + let call_anchor = Anchor::line(source_path, site.line); + let mut candidates = matching_targets(&same_file, &site.callee); + candidates.extend(matching_targets(&related, &site.callee)); + + let target = match candidates.as_slice() { + [target] => *target, + [] => continue, + candidates => { + let (anchors, omitted_candidates) = capped_candidate_anchors(candidates); + unknowns.push(DirectCallUnknown { + call_anchor, + call_callee: site.callee.clone(), + call_display, + reason: DirectCallUnknownReason::AmbiguousTarget, + candidates: anchors, + omitted_candidates, + }); + continue; + } + }; + + if caller_range.is_some_and(|(start, end)| { + same_path(source_path, &target.path) + && start == target.start_line + && end == target.end_line + }) { + unknowns.push(DirectCallUnknown { + call_anchor, + call_callee: site.callee.clone(), + call_display, + reason: DirectCallUnknownReason::SelfRecursiveCall, + candidates: vec![Anchor::lines( + &target.path, + target.start_line, + target.end_line, + )], + omitted_candidates: 0, + }); + continue; + } + + let (arg_param_mappings, omitted_arg_param_mappings, mapping_unknown) = + arg_param_mappings(source_path, &site.args, target); + edges.push(DirectCallEvidenceEdge { + call_anchor, + call_callee: site.callee.clone(), + call_display, + target_name: target.name.clone(), + target_anchor: Anchor::lines(&target.path, target.start_line, target.end_line), + confidence: target.confidence, + arg_param_mappings, + omitted_arg_param_mappings, + mapping_unknown, + }); + } + + edges.sort_by_key(edge_sort_key); + edges.dedup_by(|left, right| edge_sort_key(left) == edge_sort_key(right)); + unknowns.sort_by_key(unknown_sort_key); + unknowns.dedup_by(|left, right| unknown_sort_key(left) == unknown_sort_key(right)); + + let omitted_edges = edges.len().saturating_sub(MAX_DIRECT_CALL_EDGES); + edges.truncate(MAX_DIRECT_CALL_EDGES); + let omitted_unknowns = unknowns.len().saturating_sub(MAX_DIRECT_CALL_UNKNOWNS); + unknowns.truncate(MAX_DIRECT_CALL_UNKNOWNS); + + DirectCallEvidenceIndex { + edges, + unknowns, + omitted_edges, + omitted_unknowns, + omitted_related_files, + } +} + +pub(crate) fn call_site_display(site: &CallSite, content: &str) -> Option { + let text = site + .call_byte_range + .and_then(|(start, end)| content.get(start..end)) + .unwrap_or(&site.call_text); + let compact = text.split_whitespace().collect::>().join(" "); + (!compact.is_empty()).then_some(compact) +} + +fn collect_targets( + path: &Path, + entries: &[OutlineEntry], + names: &BTreeSet<&str>, + confidence: DirectCallResolutionConfidence, +) -> Vec { + fn visit( + path: &Path, + entries: &[OutlineEntry], + names: &BTreeSet<&str>, + confidence: DirectCallResolutionConfidence, + targets: &mut BTreeMap<(String, u32, u32, String), DirectCallTarget>, + ) { + for entry in entries { + if entry.kind == OutlineKind::Function && names.contains(entry.name.as_str()) { + let target = DirectCallTarget { + name: entry.name.clone(), + path: path.to_path_buf(), + start_line: entry.start_line, + end_line: entry.end_line, + signature: entry.signature.clone(), + confidence, + }; + targets.insert(target_sort_key(&target), target); + } + visit(path, &entry.children, names, confidence, targets); + } + } + + let mut targets = BTreeMap::new(); + visit(path, entries, names, confidence, &mut targets); + targets.into_values().collect() +} + +fn matching_targets<'a>(targets: &'a [DirectCallTarget], name: &str) -> Vec<&'a DirectCallTarget> { + targets + .iter() + .filter(|target| target.name == name) + .collect() +} + +fn capped_candidate_anchors(candidates: &[&DirectCallTarget]) -> (Vec, usize) { + let omitted = candidates.len().saturating_sub(MAX_UNKNOWN_CANDIDATES); + let anchors = candidates + .iter() + .take(MAX_UNKNOWN_CANDIDATES) + .map(|target| Anchor::lines(&target.path, target.start_line, target.end_line)) + .collect(); + (anchors, omitted) +} + +fn arg_param_mappings( + source_path: &Path, + args: &[String], + target: &DirectCallTarget, +) -> ( + Vec, + usize, + Option, +) { + if args + .iter() + .any(|arg| !is_positional_call_arg(source_path, arg)) + { + return ( + Vec::new(), + 0, + Some(ArgParamMappingUnknownReason::NonPositionalArguments), + ); + } + + let Some(signature) = target.signature.as_deref() else { + return ( + Vec::new(), + 0, + Some(ArgParamMappingUnknownReason::UnreliableSignature), + ); + }; + let Some(params) = parse_function_parameters(&target.path, signature) else { + return ( + Vec::new(), + 0, + Some(ArgParamMappingUnknownReason::UnreliableSignature), + ); + }; + if args.len() != params.len() { + return ( + Vec::new(), + 0, + Some(ArgParamMappingUnknownReason::ArityMismatch), + ); + } + + let mut mappings = args + .iter() + .zip(params) + .enumerate() + .map(|(index, (arg, param_name))| ArgParamMapping { + arg_index: index, + arg_display: arg.clone(), + param_index: index, + param_name, + }) + .collect::>(); + let omitted = mappings + .len() + .saturating_sub(MAX_ARG_PARAM_MAPPINGS_PER_EDGE); + mappings.truncate(MAX_ARG_PARAM_MAPPINGS_PER_EDGE); + (mappings, omitted, None) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SignatureParameterStyle { + NameFirst, + NameLast, + Conservative, +} + +fn parse_function_parameters(path: &Path, signature: &str) -> Option> { + let style = signature_parameter_style(path); + let (start, end) = parameter_range(signature, style)?; + let params = signature[start + 1..end].trim(); + if params.is_empty() { + return Some(Vec::new()); + } + + let parts = split_top_level_params(params)?; + let mut parsed = Vec::new(); + for part in parts { + let part = part.trim(); + if part.is_empty() || is_varargs_param(part) { + return None; + } + let name = parameter_name(part, style)?; + if is_receiver_param(&name) { + if is_syntactic_receiver_param(path, part, style) { + continue; + } + return None; + } + parsed.push(name); + } + Some(parsed) +} + +fn signature_parameter_style(path: &Path) -> SignatureParameterStyle { + match path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "go" => SignatureParameterStyle::NameFirst, + "c" | "cc" | "cpp" | "cxx" | "h" | "hh" | "hpp" | "hxx" | "java" | "cs" => { + SignatureParameterStyle::NameLast + } + _ => SignatureParameterStyle::Conservative, + } +} + +fn parameter_range(signature: &str, style: SignatureParameterStyle) -> Option<(usize, usize)> { + let ranges = top_level_parenthesis_ranges(signature)?; + if style == SignatureParameterStyle::NameFirst + && signature.trim_start().starts_with("func (") + && ranges.len() >= 2 + { + return Some(ranges[1]); + } + ranges.first().copied() +} + +fn top_level_parenthesis_ranges(signature: &str) -> Option> { + let mut ranges = Vec::new(); + let mut depth = 0usize; + let mut start = None; + for (index, ch) in signature.char_indices() { + match ch { + '(' => { + if depth == 0 { + start = Some(index); + } + depth += 1; + } + ')' => { + if depth == 0 { + return None; + } + depth -= 1; + if depth == 0 { + ranges.push((start?, index)); + start = None; + } + } + _ => {} + } + } + (depth == 0).then_some(ranges) +} + +fn split_top_level_params(params: &str) -> Option> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (index, ch) in params.char_indices() { + match ch { + '(' | '[' | '{' | '<' => depth += 1, + ')' | ']' | '}' | '>' => depth = depth.checked_sub(1)?, + ',' if depth == 0 => { + parts.push(¶ms[start..index]); + start = index + ch.len_utf8(); + } + _ => {} + } + } + if depth != 0 { + return None; + } + parts.push(¶ms[start..]); + Some(parts) +} + +fn is_varargs_param(param: &str) -> bool { + let trimmed = param.trim_start(); + trimmed.contains("...") || trimmed.starts_with('*') +} + +fn parameter_name(param: &str, style: SignatureParameterStyle) -> Option { + let before_default = param.split('=').next()?.trim(); + if before_default.eq_ignore_ascii_case("void") { + return None; + } + if style != SignatureParameterStyle::NameLast && before_default.contains(':') { + return colon_parameter_name(before_default); + } + match style { + SignatureParameterStyle::NameFirst => first_named_parameter_identifier(before_default), + SignatureParameterStyle::NameLast => last_named_parameter_identifier(before_default), + SignatureParameterStyle::Conservative => conservative_parameter_name(before_default), + } +} + +fn colon_parameter_name(param: &str) -> Option { + let before_type = strip_binding_prefixes(param.split(':').next()?.trim()); + is_identifier(before_type).then(|| before_type.to_string()) +} + +fn first_named_parameter_identifier(value: &str) -> Option { + (value.split_whitespace().count() >= 2) + .then(|| value.split_whitespace().find_map(identifier_from_token))? +} + +fn last_named_parameter_identifier(value: &str) -> Option { + let tokens = value.split_whitespace().collect::>(); + if tokens.len() < 2 || tokens.first().is_some_and(|token| *token == "this") { + return None; + } + let name = identifier_from_token(tokens.last()?)?; + if is_type_only_identifier(&name) + || is_name_last_qualifier_or_tag(&name) + || tokens[..tokens.len() - 1] + .iter() + .all(|token| is_name_last_qualifier_or_tag(token)) + { + None + } else { + Some(name) + } +} + +fn is_name_last_qualifier_or_tag(value: &str) -> bool { + matches!( + value, + "const" + | "volatile" + | "final" + | "static" + | "readonly" + | "unsigned" + | "signed" + | "struct" + | "enum" + | "union" + | "class" + ) +} + +fn is_type_only_identifier(value: &str) -> bool { + matches!( + value, + "char" + | "short" + | "int" + | "long" + | "float" + | "double" + | "void" + | "bool" + | "boolean" + | "byte" + | "string" + | "String" + ) +} + +fn conservative_parameter_name(value: &str) -> Option { + let value = strip_binding_prefixes(value); + is_identifier(value).then(|| value.to_string()) +} + +fn strip_binding_prefixes(mut value: &str) -> &str { + value = value.trim(); + for prefix in ["&mut ", "&", "mut ", "ref "] { + if let Some(stripped) = value.strip_prefix(prefix) { + return stripped.trim(); + } + } + value +} + +fn identifier_from_token(token: &str) -> Option { + let token = token + .trim() + .trim_start_matches(['&', '*']) + .split('[') + .next() + .unwrap_or_default() + .trim_matches(|ch: char| ch != '_' && !ch.is_ascii_alphanumeric()); + is_identifier(token).then(|| token.to_string()) +} + +fn is_syntactic_receiver_param(path: &Path, param: &str, style: SignatureParameterStyle) -> bool { + if !is_rust_path(path) + || matches!( + style, + SignatureParameterStyle::NameFirst | SignatureParameterStyle::NameLast + ) + { + return false; + } + let before_default = param.split('=').next().unwrap_or_default().trim(); + let before_type = before_default + .split(':') + .next() + .unwrap_or(before_default) + .trim(); + before_type == "self" + || before_type == "mut self" + || (before_type.starts_with('&') && strip_binding_prefixes(before_type) == "self") +} + +fn is_rust_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("rs")) +} + +fn is_receiver_param(name: &str) -> bool { + matches!(name, "self" | "this") +} + +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_positional_call_arg(path: &Path, arg: &str) -> bool { + let arg = arg.trim(); + if arg.is_empty() || arg.starts_with("...") || arg.starts_with("**") { + return false; + } + if arg.starts_with('*') && is_python_or_ruby_splat_path(path) { + return false; + } + !arg.contains('=') && !arg.contains(':') +} + +fn is_python_or_ruby_splat_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "py" | "pyi" | "rb")) + || path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| matches!(name, "Rakefile" | "Vagrantfile")) +} + +fn same_path(left: &Path, right: &Path) -> bool { + crate::format::display_path(left) == crate::format::display_path(right) +} + +fn target_sort_key(target: &DirectCallTarget) -> (String, u32, u32, String) { + ( + crate::format::display_path(&target.path), + target.start_line, + target.end_line, + target.name.clone(), + ) +} + +fn edge_sort_key(edge: &DirectCallEvidenceEdge) -> (u32, String, String, String) { + ( + edge.call_anchor.start_line(), + edge.call_callee.clone(), + edge.target_anchor.display(), + edge.call_display.clone(), + ) +} + +fn unknown_sort_key(unknown: &DirectCallUnknown) -> (u32, String, DirectCallUnknownReason, String) { + ( + unknown.call_anchor.start_line(), + unknown.call_callee.clone(), + unknown.reason, + unknown.call_display.clone(), + ) +} + +#[cfg(test)] +#[path = "direct_call/tests.rs"] +mod tests; diff --git a/src/evidence/direct_call/tests.rs b/src/evidence/direct_call/tests.rs new file mode 100644 index 0000000..b2510b5 --- /dev/null +++ b/src/evidence/direct_call/tests.rs @@ -0,0 +1,277 @@ +use super::*; +use crate::search::callees::extract_call_sites; + +fn build( + path: &Path, + content: &str, + lang: Lang, + caller_range: (u32, u32), +) -> DirectCallEvidenceIndex { + let sites = extract_call_sites(content, lang, Some(caller_range)); + build_direct_call_evidence_index(path, content, lang, Some(caller_range), &sites) +} + +#[test] +fn maps_same_file_rust_and_javascript_positional_arguments() { + let rust = r#" +fn helper(user: User, path: &str) {} +fn caller(user: User, path: &str) { + helper(user, path); +} +"#; + let rust_index = build(Path::new("src/lib.rs"), rust, Lang::Rust, (3, 5)); + assert_eq!(rust_index.edges().len(), 1); + let rust_edge = &rust_index.edges()[0]; + assert_eq!( + rust_edge + .arg_param_mappings() + .iter() + .map(|mapping| { + ( + mapping.arg_index(), + mapping.arg_display(), + mapping.param_index(), + mapping.param_name(), + ) + }) + .collect::>(), + vec![(0, "user", 0, "user"), (1, "path", 1, "path")] + ); + assert_eq!( + rust_edge.confidence(), + DirectCallResolutionConfidence::SameFileStructural + ); + + let javascript = r#" +function helper(user, path) {} +function caller(user, path) { + helper(user, path); +} +"#; + let js_index = build( + Path::new("src/lib.js"), + javascript, + Lang::JavaScript, + (3, 5), + ); + assert_eq!(js_index.edges().len(), 1); + assert_eq!(js_index.edges()[0].arg_param_mappings().len(), 2); +} + +#[test] +fn retains_edge_but_labels_unreliable_mapping_inputs() { + let arity = r#" +fn helper(user: User, path: &str) {} +fn caller(user: User) { + helper(user); +} +"#; + let arity_index = build(Path::new("src/lib.rs"), arity, Lang::Rust, (3, 5)); + assert_eq!(arity_index.edges().len(), 1); + assert_eq!( + arity_index.edges()[0].mapping_unknown(), + Some(ArgParamMappingUnknownReason::ArityMismatch) + ); + assert_eq!(arity_index.edges()[0].omitted_arg_param_mappings(), 0); + + let spread = r#" +function helper(value) {} +function caller(values) { + helper(...values); +} +"#; + let spread_index = build(Path::new("src/lib.js"), spread, Lang::JavaScript, (3, 5)); + assert_eq!(spread_index.edges().len(), 1); + assert_eq!( + spread_index.edges()[0].mapping_unknown(), + Some(ArgParamMappingUnknownReason::NonPositionalArguments) + ); + assert!(spread_index.edges()[0].arg_param_mappings().is_empty()); +} + +#[test] +fn ambiguous_and_self_recursive_targets_abstain_from_edges() { + let ambiguous = r#" +fn helper(value: i32) {} +fn helper(value: i64) {} +fn caller(value: i32) { + helper(value); +} +"#; + let ambiguous_index = build(Path::new("src/lib.rs"), ambiguous, Lang::Rust, (4, 6)); + assert!(ambiguous_index.edges().is_empty()); + assert_eq!(ambiguous_index.unknowns().len(), 1); + assert_eq!( + ambiguous_index.unknowns()[0].reason(), + DirectCallUnknownReason::AmbiguousTarget + ); + assert_eq!(ambiguous_index.unknowns()[0].candidates().len(), 2); + + let recursive = r#" +fn caller(value: i32) { + caller(value); +} +"#; + let recursive_index = build(Path::new("src/lib.rs"), recursive, Lang::Rust, (2, 4)); + assert!(recursive_index.edges().is_empty()); + assert_eq!( + recursive_index.unknowns()[0].reason(), + DirectCallUnknownReason::SelfRecursiveCall + ); +} + +#[test] +fn maps_explicit_related_file_target() { + let root = std::env::temp_dir().join(format!( + "srcwalk_direct_call_related_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let caller_path = root.join("lib.rs"); + let related_path = root.join("service.rs"); + let caller = r#" +mod service; +use self::service::apply_update; +fn caller(record_id: i32) { + apply_update(record_id); +} +"#; + std::fs::write(&caller_path, caller).unwrap(); + std::fs::write(&related_path, "pub fn apply_update(record_id: i32) {}\n").unwrap(); + + let index = build(&caller_path, caller, Lang::Rust, (4, 6)); + assert_eq!(index.edges().len(), 1); + let edge = &index.edges()[0]; + assert_eq!( + edge.confidence(), + DirectCallResolutionConfidence::ExplicitRelatedFileStructural + ); + assert!( + edge.target_anchor().display().contains("service.rs"), + "expected related target anchor, got {}", + edge.target_anchor().display() + ); + assert_eq!(edge.arg_param_mappings()[0].param_name(), "record_id"); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn duplicate_same_and_related_file_targets_abstain() { + let root = std::env::temp_dir().join(format!( + "srcwalk_direct_call_duplicate_related_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let caller_path = root.join("lib.rs"); + let related_path = root.join("service.rs"); + let caller = r#" +mod service; +use self::service::apply_update; +fn apply_update(record_id: i32) {} +fn caller(record_id: i32) { + apply_update(record_id); +} +"#; + std::fs::write(&caller_path, caller).unwrap(); + std::fs::write(&related_path, "pub fn apply_update(record_id: i32) {}\n").unwrap(); + + let index = build(&caller_path, caller, Lang::Rust, (5, 7)); + assert!(index.edges().is_empty()); + assert_eq!(index.unknowns().len(), 1); + assert_eq!( + index.unknowns()[0].reason(), + DirectCallUnknownReason::AmbiguousTarget + ); + assert_eq!(index.unknowns()[0].candidates().len(), 2); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn qualified_calls_and_unresolved_calls_abstain_without_hiding_actionable_unknowns() { + let qualified = r#" +fn save(value: i32) {} +fn caller(client: Client, value: i32) { + client.save(value); +} +"#; + let qualified_index = build(Path::new("src/lib.rs"), qualified, Lang::Rust, (3, 5)); + assert!(qualified_index.edges().is_empty()); + assert!(qualified_index.unknowns().is_empty()); + + let mut crowded = String::from( + "fn helper(value: i32) {}\nfn helper(value: i64) {}\nfn caller(value: i32) {\n", + ); + for index in 0..(MAX_DIRECT_CALL_UNKNOWNS + 4) { + crowded.push_str(&format!(" external{index}(value);\n")); + } + crowded.push_str(" helper(value);\n}\n"); + let end_line = crowded.lines().count() as u32; + let crowded_index = build(Path::new("src/lib.rs"), &crowded, Lang::Rust, (3, end_line)); + assert!(crowded_index.edges().is_empty()); + assert_eq!(crowded_index.unknowns().len(), 1); + assert_eq!( + crowded_index.unknowns()[0].reason(), + DirectCallUnknownReason::AmbiguousTarget + ); + assert_eq!(crowded_index.omitted_unknowns(), 0); +} + +#[test] +fn mapping_parser_supports_go_and_name_last_signatures() { + assert_eq!( + parse_function_parameters( + Path::new("src/lib.go"), + "func (s *Service) Save(ctx context.Context, id string) error" + ), + Some(vec!["ctx".to_string(), "id".to_string()]) + ); + assert_eq!( + parse_function_parameters( + Path::new("src/lib.c"), + "void save(const char *path, unsigned int mode)" + ), + Some(vec!["path".to_string(), "mode".to_string()]) + ); + assert!(parse_function_parameters( + Path::new("src/lib.rs"), + "fn helper(values: impl Iterator)" + ) + .is_some()); +} + +#[test] +fn arg_param_mapping_is_capped_with_omitted_count() { + let target = DirectCallTarget { + name: "helper".to_string(), + path: PathBuf::from("src/lib.rs"), + start_line: 1, + end_line: 2, + signature: Some( + "fn helper(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32, i: i32)" + .to_string(), + ), + confidence: DirectCallResolutionConfidence::SameFileStructural, + }; + let args = ["a", "b", "c", "d", "e", "f", "g", "h", "i"] + .into_iter() + .map(str::to_string) + .collect::>(); + let (mappings, omitted, unknown) = arg_param_mappings(Path::new("src/lib.rs"), &args, &target); + assert_eq!(mappings.len(), MAX_ARG_PARAM_MAPPINGS_PER_EDGE); + assert_eq!(omitted, 1); + assert_eq!(unknown, None); + assert_eq!( + ArgParamMapping::confidence(), + "syntactic positional arg/param" + ); +} diff --git a/src/evidence/local_links.rs b/src/evidence/local_links.rs index 843bece..18cf0ef 100644 --- a/src/evidence/local_links.rs +++ b/src/evidence/local_links.rs @@ -436,7 +436,7 @@ fn assignment_link(node: Node<'_>, source: &[u8], path: &Path) -> Option bool { ) } -fn is_call_like(kind: &str, text: &str) -> bool { - kind.contains("call") - || kind.contains("invocation") - || kind.contains("creation") - || text.contains('(') +fn is_call_like(kind: &str) -> bool { + kind.contains("call") || kind.contains("invocation") || kind.contains("creation") } fn is_field_like(text: &str) -> bool { diff --git a/src/evidence/local_links/tests.rs b/src/evidence/local_links/tests.rs index f1ed5b1..b64b271 100644 --- a/src/evidence/local_links/tests.rs +++ b/src/evidence/local_links/tests.rs @@ -209,3 +209,20 @@ fn predecessor_chain_is_bounded_and_abstains_on_ambiguity() { ]); assert!(ambiguous.unique_predecessor_chain("alias", 2).is_none()); } + +#[test] +fn parenthesized_field_expression_is_not_a_call_result() { + let rust = r#" +fn demo(req: Request) { + let value = (req.value); + sink(value); +} +"#; + let graph = + collect_local_links_for_function(Path::new("src/lib.rs"), rust, Lang::Rust, "demo", 1, 4); + + assert!(graph + .links() + .iter() + .all(|link| link.kind() != LocalLinkKind::CallResult || link.to().identity() != "value")); +} diff --git a/src/evidence/mod.rs b/src/evidence/mod.rs index 2ffa4d4..39e888f 100644 --- a/src/evidence/mod.rs +++ b/src/evidence/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod anchor; pub(crate) mod atom; pub(crate) mod confidence; +pub(crate) mod direct_call; pub(crate) mod local_links; pub(crate) mod next_action; diff --git a/tests/callees_detailed.rs b/tests/callees_detailed.rs index e7c8623..26843cc 100644 --- a/tests/callees_detailed.rs +++ b/tests/callees_detailed.rs @@ -15,6 +15,19 @@ fn fixture_dir() -> &'static Path { )) } +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "srcwalk_{name}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + /// Set up a minimal fixture with known call structure. fn setup_fixtures() { SETUP_FIXTURES.call_once(|| { @@ -248,6 +261,64 @@ fn callees_default_preserves_unresolved_names_after_row_cap() { ); } +#[test] +fn callees_detailed_shows_direct_call_mapping_and_ambiguity_without_unresolved_spam() { + let dir = temp_dir("callees_direct_call_evidence"); + std::fs::write( + dir.join("lib.rs"), + r#"fn helper(user: &str, count: i32) {} + +fn duplicate(value: i32) {} +fn duplicate(value: i64) {} + +fn entry(user: &str, count: i32, value: i32) { + helper(user, count); + duplicate(value); + external(value); +} +"#, + ) + .unwrap(); + + let out = srcwalk() + .args(["trace", "callees", "entry", "--detailed", "--scope"]) + .arg(&dir) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!( + out.status.success(), + "trace callees --detailed should succeed, stderr:\n{stderr}\nstdout:\n{stdout}" + ); + assert!( + stdout.contains("helper(arg1=user, arg2=count)"), + "expected detailed call-site args, got:\n{stdout}" + ); + assert!( + stdout.contains("-> [fn] helper") + && stdout.contains("confidence: same-file structural direct call"), + "expected same-file direct-call evidence, got:\n{stdout}" + ); + assert!( + stdout.contains("arg0 `user` -> param0 `user`") + && stdout.contains("arg1 `count` -> param1 `count`"), + "expected positional arg/param mappings, got:\n{stdout}" + ); + assert!( + stdout.contains("direct target: unknown (ambiguous structural target)") + && stdout.contains("candidate:"), + "expected ambiguity abstention with candidates, got:\n{stdout}" + ); + assert!( + !stdout.contains("direct target: unknown (unresolved structural target)"), + "unresolved external calls should not add detailed direct-target spam:\n{stdout}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn callees_python_detailed() { setup_fixtures(); diff --git a/tests/flow_filter.rs b/tests/flow_filter.rs index 410cf01..40a42bc 100644 --- a/tests/flow_filter.rs +++ b/tests/flow_filter.rs @@ -1018,6 +1018,115 @@ fn handle(req: Request) { !local_section.contains("req.first") && !local_section.contains("one(first)"), "filtered local-link section leaked hidden call evidence:\n{local_section}" ); + let direct_section = stdout + .split("### Direct-call evidence") + .nth(1) + .and_then(|rest| rest.split("### Resolved local callees").next()) + .expect("filtered call should retain one direct-call evidence section"); + assert!( + direct_section.contains("two(second)"), + "filtered direct-call evidence should include the visible call:\n{direct_section}" + ); + assert!( + !direct_section.contains("one(first)"), + "filtered direct-call evidence leaked hidden call evidence:\n{direct_section}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_direct_call_evidence_is_limited_to_visible_call_rows() { + let dir = temp_dir("context_capped_direct_calls"); + let file = dir.join("lib.rs"); + let mut source = String::new(); + for index in 0..13 { + source.push_str(&format!("fn helper{index}(value: i32) {{}}\n")); + } + source.push_str("fn handle(value: i32) {\n"); + for index in 0..13 { + source.push_str(&format!(" helper{index}(value);\n")); + } + source.push_str("}\n"); + fs::write(&file, source).unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + let direct_section = stdout + .split("### Direct-call evidence") + .nth(1) + .and_then(|rest| rest.split("### Resolved local callees").next()) + .expect("context should render direct-call evidence"); + let rendered_rows = direct_section + .lines() + .filter(|line| line.starts_with("- L") && line.contains("helper")) + .count(); + assert_eq!( + rendered_rows, 12, + "direct-call evidence rows must follow the visible call-site cap:\n{direct_section}" + ); + assert!( + stdout.contains("... 1 more call sites"), + "context should still report capped call-site rows:\n{stdout}" + ); + assert!( + direct_section.contains("helper0(value)") + && direct_section.contains("arg0 `value` -> param0 `value`"), + "expected resolved mapping for a visible direct call:\n{direct_section}" + ); + assert!( + !direct_section.contains("helper12(value)") + && !direct_section.contains("direct-call edges omitted"), + "direct-call evidence should be built only from visible call rows:\n{direct_section}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_local_links_ignore_calls_beyond_visible_neighborhood() { + let dir = temp_dir("context_hidden_call_local_links"); + let file = dir.join("lib.rs"); + let mut source = String::from( + "struct Request { hidden: i32 }\nfn sink(_: i32) {}\nfn handle(req: Request) {\n", + ); + for index in 0..12 { + source.push_str(&format!(" sink({index});\n")); + } + source.push_str(" let hidden = req.hidden;\n sink(hidden);\n}\n"); + fs::write(&file, source).unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + assert!(stdout.contains("... 1 more call sites")); + assert!( + !stdout.contains("### Local structural links") + && !stdout.contains("req.hidden -> hidden") + && !stdout.contains("hidden -> sink(hidden)"), + "hidden call must not influence visible local-link evidence:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn context_local_links_reject_hidden_call_result_predecessors() { + let dir = temp_dir("context_hidden_call_result_predecessor"); + let file = dir.join("lib.rs"); + let mut source = String::from( + "fn build() -> i32 { 1 }\nfn sink(_: i32) {}\nfn handle() {\n sink(value);\n", + ); + for index in 0..11 { + source.push_str(&format!(" sink({index});\n")); + } + source.push_str(" let value = build();\n}\n"); + fs::write(&file, source).unwrap(); + + let stdout = context_output(&dir, &file, "handle"); + assert!(stdout.contains("... 1 more call sites")); + assert!( + !stdout.contains("### Local structural links") + && !stdout.contains("build() -> value [call_result]"), + "hidden call result must not enter a visible call predecessor chain:\n{stdout}" + ); let _ = fs::remove_dir_all(&dir); } From c355d219f402172a3519541279c3dbb57bef5452 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 15:52:11 +0700 Subject: [PATCH 5/9] docs: refresh public evidence contracts Document bounded local/direct-call evidence, regenerate checked-in CLI transcripts from the current binary, and keep output-visible routing details out of the distributed guide. --- CHANGELOG.md | 6 ++ README.md | 133 +++++++++++++++++++++++----------------- skills/srcwalk/GUIDE.md | 8 +-- 3 files changed, 88 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de372a5..c90ed81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to srcwalk are documented here. ## Unreleased +### Added +- Added bounded local structural links to context call neighborhoods and bounded unique direct-call evidence with positional argument-to-parameter mappings to context and `trace callees --detailed`. Evidence uses exact source anchors and abstains when targets or local predecessors are ambiguous. + +### Changed +- Aligned CLI error and overflow wording with command behavior, including unresolved callee labels and discover mode errors. + ## [1.0.1] - 2026-05-26 ### Changed diff --git a/README.md b/README.md index 9d31c2e..4334b01 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ one binary, zero config. - **Show** — read files, line ranges, symbols, headings, and capped raw pages. - **Discover** — find definitions, usages, files, text, comments, and field/member access evidence. -- **Trace** — inspect callers and callees with bounded depth, hub guards, and - unresolved-call labels. -- **Context** — build one-target packets with Flow Maps, local evidence, call - neighborhoods, and exact next commands. +- **Trace** — inspect callers and callees with bounded depth, hub guards, + unresolved-call labels, and bounded direct-call evidence. +- **Context** — build one-target packets with Flow Maps, local structural links, + call neighborhoods, and exact next commands. - **Review & diff** — turn Git changes into bounded evidence packets for changed files, symbols, and untracked files. - **Compare & assess** — compare two known targets structurally, or scan blast @@ -154,7 +154,7 @@ Examples below use this repository. Timings may vary between machines; snippets ``` $ srcwalk src/evidence/next_action.rs -# src/evidence/next_action.rs (198 lines, ~1.2k tokens) [outline] +# src/evidence/next_action.rs (200 lines, ~1.2k tokens) [outline] [1-] imports: std::collections::BTreeMap, std::fmt::Write as _, crate::evidence [7-13] struct NextAction @@ -183,13 +183,20 @@ $ srcwalk src/evidence/next_action.rs [109-118] mod impl NextActionConfidence [110-117] fn sort_rank const fn sort_rank(self) -> u8 -[120-127] mod impl NextActionConfidence - [121-126] fn from +[120-129] mod impl NextActionConfidence + [121-128] fn from fn from(source: EvidenceSource) -> Self -[129-139] fn render_next_actions +[131-141] fn render_next_actions pub(crate) fn render_next_actions(actions: &[NextAction]) -> String -[141-157] fn ordered_unique +[143-159] fn ordered_unique fn ordered_unique(actions: &[NextAction]) -> Vec +[162-199] mod tests + [163] import use std::path::Path; + [165] import use super::*; + [168-185] fn render_orders_by_rank_then_dedupes_by_command + fn render_orders_by_rank_then_dedupes_by_command() + [188-198] fn duplicate_commands_keep_best_rank + fn duplicate_commands_keep_best_rank() > Next: drill into a symbol with --section or a line range > Next: need raw file text? retry with --full, or use --section for a smaller slice. @@ -212,20 +219,20 @@ $ srcwalk src/evidence/next_action.rs --section "NextAction,render_next_actions, --- -## section: render_next_actions [129-139] (compact) +## section: render_next_actions [131-141] (compact) - 129 │ pub(crate) fn render_next_actions(actions: &[NextAction]) -> String { - 130 │ let actions = ordered_unique(actions); - 131 │ let mut out = String::new(); + 131 │ pub(crate) fn render_next_actions(actions: &[NextAction]) -> String { + 132 │ let actions = ordered_unique(actions); + 133 │ let mut out = String::new(); ... 8 lines omitted; narrow --section or raise --budget. --- -## section: ordered_unique [141-157] (compact) +## section: ordered_unique [143-159] (compact) - 141 │ fn ordered_unique(actions: &[NextAction]) -> Vec { - 142 │ let mut by_command = BTreeMap::::new(); - 143 │ for action in actions { + 143 │ fn ordered_unique(actions: &[NextAction]) -> Vec { + 144 │ let mut by_command = BTreeMap::::new(); + 145 │ for action in actions { ... 14 lines omitted; narrow --section or raise --budget. > Caveat: compacted ~272/260 tokens; shown 3 symbols. @@ -243,40 +250,44 @@ confidence: structural syntax caveat: source-evidence navigation only; no runtime proof ## Target -- src/evidence/next_action.rs:141-157 ordered_unique +- src/evidence/next_action.rs:143-159 ordered_unique ## Flow Map shape: 1 entry, 0 decisions, 1 loop, 1 exit, 4 actions -N1 entry :141-157 entry - next -> N2 action :142 BTreeMap::::new() -N2 action :142 BTreeMap::::new() - calls: BTreeMap::::new :142 - next -> N3 loop :143-152 actions -N3 loop :143-152 actions - body -> N4 action :144-151 by_command .entry(action.command.clone()) .and_modify(|existing| { if action.sort_key() < exist… - next -> N5 action :154 by_command.into_values().collect() -N4 action :144-151 by_command .entry(action.command.clone()) .and_modify(|existing| { if action.sort_key() < exist… - calls: by_command .entry :144 - reads: action.clone call_arg :151 - loop_back -> N3 loop :143-152 actions -N5 action :154 by_command.into_values().collect() - calls: by_command.into_values :154 - next -> N6 action :155 actions.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())) -N6 action :155 actions.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())) - calls: actions.sort_by :155 - reads: left.sort_key().cmp call_arg :155; right.sort_key call_arg :155 - next -> N7 return :157 end -N7 return :157 end +N1 entry :143-159 entry + definitions: actions parameter :143 + next -> N2 action :144 BTreeMap::::new() +N2 action :144 BTreeMap::::new() + calls: BTreeMap::::new :144 + writes: by_command assignment_lhs :144 + next -> N3 loop :145-154 actions +N3 loop :145-154 actions + reads: actions condition :145 + body -> N4 action :146-153 by_command .entry(action.command.clone()) .and_modify(|existing| { if action.sort_key() < exist… + next -> N5 action :156 by_command.into_values().collect() +N4 action :146-153 by_command .entry(action.command.clone()) .and_modify(|existing| { if action.sort_key() < exist… + calls: by_command .entry :146 + reads: action.clone call_arg :153 + loop_back -> N3 loop :145-154 actions +N5 action :156 by_command.into_values().collect() + calls: by_command.into_values :156 + writes: actions assignment_lhs :156 + next -> N6 action :157 actions.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())) +N6 action :157 actions.sort_by(|left, right| left.sort_key().cmp(&right.sort_key())) + calls: actions.sort_by :157 + reads: left call_arg :157; right call_arg :157; left.sort_key().cmp call_arg :157; +1 more + next -> N7 return :159 end +N7 return :159 end ## Exits -- :157 end +- :159 end ## Call Neighborhood ### Callees (ordered) -- L142 by_command = BTreeMap::::new() -- L151 by_command.entry(action.command.clone()).and_modify(|existing| { if action.sort_key() < existing.sort_key() { *existing = action.clone(); } }).or_insert_with(arg1=|| action.clone()) -- L154 actions = by_command.into_values().collect() -- L155 actions.sort_by(arg1=|left, right| left.sort_key().cmp(&right.sort_key())) +- L144 by_command = BTreeMap::::new() +- L153 by_command.entry(action.command.clone()).and_modify(|existing| { if action.sort_key() < existing.sort_key() { *existing = action.clone(); } }).or_insert_with(arg1=|| action.clone()) +- L156 actions = by_command.into_values().collect() +- L157 actions.sort_by(arg1=|left, right| left.sort_key().cmp(&right.sort_key())) ### Resolved local callees [fn] NextAction src/evidence/next_action.rs:7-13 @@ -285,11 +296,11 @@ N7 return :157 end ### Callers -- [fn] render_next_actions src/evidence/next_action.rs:130 +- [fn] render_next_actions src/evidence/next_action.rs:132 > Caveat: static context packet is capped; verify exact edges with trace commands. -> Next: srcwalk show src/evidence/next_action.rs:141-157 -C 20 +> Next: srcwalk show src/evidence/next_action.rs:143-159 -C 20 > Next: srcwalk trace callers ordered_unique > Next: srcwalk trace callees ordered_unique --detailed ``` @@ -340,18 +351,20 @@ hunks: $ srcwalk discover "render_next_actions, Anchor" --scope src/evidence --scope src/commands --limit 2 # Search: "render_next_actions" in 2 scopes — 2 matches (1 definitions, 1 usages) Scopes on this page: src/evidence (2), src/commands (0) - [fn] render_next_actions src/evidence/next_action.rs:129-139 + [fn] render_next_actions src/evidence/next_action.rs:131-141 + source: ast · kind: definition · confidence: structural syntax -## src/evidence/mod.rs:9 [usage] -→ [9] pub(crate) use next_action::{render_next_actions, NextAction}; +## src/evidence/mod.rs:14 [usage] +source: text · kind: usage · confidence: text evidence +→ [14] pub(crate) use next_action::{render_next_actions, NextAction}; ## Confirmed next context targets -> Next: srcwalk context src/evidence/next_action.rs:129-139 +> Next: srcwalk context src/evidence/next_action.rs:131-141 -(~101 tokens) +(~133 tokens) > Next: 25 more matches available. Continue with --offset 2 --limit 2. -> Next: choose a confirmed context target above, or read raw hit evidence with `srcwalk show : -C 10`. +> Next: choose a confirmed context target above, or read exact hit evidence with `srcwalk show : -C 10`. --- # Search: "Anchor" in 2 scopes — 2 matches (1 definitions, 1 usages) @@ -376,9 +389,19 @@ Scopes on this page: src/evidence (2), src/commands (0) pub(crate) fn display_relative_to(&self, scope: &Path) -> String [58-64] fn display_with_path fn display_with_path(&self, path: &str) -> String +[68-106] mod tests + [69] import use super::*; + [72-80] fn line_anchor_uses_existing_display_path + fn line_anchor_uses_existing_display_path() + [83-93] fn range_anchor_uses_existing_relative_display_path + fn range_anchor_uses_existing_relative_display_path() + [96-105] fn file_anchor_uses_existing_relative_display_path + fn file_anchor_uses_existing_relative_display_path() [struct] Anchor src/evidence/anchor.rs:6-9 + source: ast · kind: definition · confidence: structural syntax ## src/evidence/anchor.rs:18 [usage] +source: text · kind: usage · confidence: text evidence → [18] impl Anchor { [6-9] struct Anchor [12-16] enum AnchorRange @@ -386,10 +409,10 @@ Scopes on this page: src/evidence (2), src/commands (0) [19-24] fn file pub(crate) fn file(path: &Path) -> Self -(~418 tokens) +(~449 tokens) -> Next: 38 more matches available. Continue with --offset 2 --limit 2. -> Next: choose a confirmed context target above, or read raw hit evidence with `srcwalk show : -C 10`. +> Next: 64 more matches available. Continue with --offset 2 --limit 2. +> Next: read exact hit evidence with `srcwalk show : -C 10`. ``` @@ -466,7 +489,7 @@ Bloom-filter pruning + length-sorted memchr + tree-sitter parse cache. `trace callees`, `assess`, `deps`, `overview`. - **Target-first reading** — `srcwalk `, `:`, and `--section `. - **Multi-hop caller BFS** — up to 5 hops, hub guard, collision detection. -- **Forward callees** — resolved/unresolved calls, detailed ordered call sites, and depth support. +- **Forward callees** — resolved/unresolved calls, detailed ordered call sites, bounded unique-target argument mappings, and depth support. - **Search ergonomics** — cross-naming-convention Did-you-mean, bare-filename auto-pick, typo tolerance. - **Performance** — mmap walkers, Aho-Corasick, rayon-parallel search, mimalloc. diff --git a/skills/srcwalk/GUIDE.md b/skills/srcwalk/GUIDE.md index 727854d..e9d1e89 100644 --- a/skills/srcwalk/GUIDE.md +++ b/skills/srcwalk/GUIDE.md @@ -33,9 +33,9 @@ Use the smallest subset of this flow that proves the task. For broad, unfamiliar request / bug / feature question -> srcwalk overview --scope -> srcwalk discover --scope - -> if output prints `## Confirmed next context targets`, run the matching `srcwalk context :` - -> otherwise read exact evidence with `srcwalk show : -C 10` - -> use `srcwalk show :` for exact source drilldown + -> pick one plausible target from discovery output + -> srcwalk context --scope + -> srcwalk show : -> srcwalk trace callers --scope -> srcwalk trace callees --detailed --scope -> srcwalk deps @@ -187,7 +187,7 @@ srcwalk dist/app.min.js --artifact # artifact-level outline for bundled/minifie ## Supported structural languages -Code/source structure varies by command: Rust, TypeScript/TSX, JavaScript, Python, Go, Java/Scala/Kotlin, C/C++, Ruby, PHP, C#, Swift, Elixir, CSS/SCSS/Less. `context`/Flow Map support is narrower: trust confirmed context targets emitted by srcwalk, and otherwise use `show`, `trace`, or `deps` instead of guessing command support. +Code/source structure: Rust, TypeScript/TSX, JavaScript, Python, Go, Java/Scala/Kotlin, C/C++, Ruby, PHP, C#, Swift, Elixir, CSS/SCSS/Less. Documents: HTML/HTM plus Markdown-style `.md`, `.mdx`, `.rst` fallback. Covers sections, elements, code blocks, links, assets. Treat document output as navigation evidence, not rendered or runtime proof. From 9de47d42c695ed91814f8fef953f68d709377519 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 17:01:20 +0700 Subject: [PATCH 6/9] fix(evidence): preserve positional expression mappings --- src/evidence/direct_call.rs | 56 +++++++++++++++++++++++++++++- src/evidence/direct_call/tests.rs | 57 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/evidence/direct_call.rs b/src/evidence/direct_call.rs index ca394d4..8ccdcd1 100644 --- a/src/evidence/direct_call.rs +++ b/src/evidence/direct_call.rs @@ -762,7 +762,61 @@ fn is_positional_call_arg(path: &Path, arg: &str) -> bool { if arg.starts_with('*') && is_python_or_ruby_splat_path(path) { return false; } - !arg.contains('=') && !arg.contains(':') + matches!(top_level_named_argument_marker(arg), Some(false)) +} + +fn top_level_named_argument_marker(arg: &str) -> Option { + let bytes = arg.as_bytes(); + let mut delimiters = Vec::new(); + let mut quote = None; + let mut escaped = false; + + for (index, &byte) in bytes.iter().enumerate() { + if let Some(active_quote) = quote { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == active_quote { + quote = None; + } + continue; + } + + match byte { + b'\'' | b'"' | b'`' => quote = Some(byte), + b'(' => delimiters.push(b')'), + b'[' => delimiters.push(b']'), + b'{' => delimiters.push(b'}'), + b')' | b']' | b'}' => { + if delimiters.pop() != Some(byte) { + return None; + } + } + b'=' if delimiters.is_empty() => { + let previous = index.checked_sub(1).and_then(|offset| bytes.get(offset)); + let next = bytes.get(index + 1); + let is_operator = matches!(previous, Some(b'!' | b'<' | b'>' | b'=')) + || matches!(next, Some(b'=' | b'>')); + if !is_operator && is_identifier(arg[..index].trim()) { + return Some(true); + } + } + b':' if delimiters.is_empty() => { + let previous = index.checked_sub(1).and_then(|offset| bytes.get(offset)); + let next = bytes.get(index + 1); + if previous != Some(&b':') + && next != Some(&b':') + && is_identifier(arg[..index].trim()) + { + return Some(true); + } + } + _ => {} + } + } + + (quote.is_none() && delimiters.is_empty()).then_some(false) } fn is_python_or_ruby_splat_path(path: &Path) -> bool { diff --git a/src/evidence/direct_call/tests.rs b/src/evidence/direct_call/tests.rs index b2510b5..189bd5a 100644 --- a/src/evidence/direct_call/tests.rs +++ b/src/evidence/direct_call/tests.rs @@ -89,6 +89,63 @@ function caller(values) { assert!(spread_index.edges()[0].arg_param_mappings().is_empty()); } +#[test] +fn positional_argument_detection_ignores_nested_syntax_and_operators() { + let rust_path = Path::new("src/lib.rs"); + for arg in [ + "count >= 0", + "left == right", + "left != right", + "left <= right", + "Point { x: 1 }", + "predicate(value = other)", + "module::VALUE", + "\"kind=value:ok\"", + "value => value", + ] { + assert!( + is_positional_call_arg(rust_path, arg), + "expected positional argument: {arg}" + ); + } + + assert!(!is_positional_call_arg( + Path::new("src/lib.py"), + "label = value" + )); + assert!(!is_positional_call_arg( + Path::new("src/lib.rb"), + "label: value" + )); + assert!(!is_positional_call_arg(rust_path, "Point { x: 1")); +} + +#[test] +fn maps_rust_and_go_arguments_containing_comparisons_and_literals() { + let rust = r#" +struct Point { x: i32 } +fn helper(condition: bool, point: Point, label: &str) {} +fn caller(count: i32) { + helper(count >= 0, Point { x: 1 }, "kind=value:ok"); +} +"#; + let rust_index = build(Path::new("src/lib.rs"), rust, Lang::Rust, (4, 6)); + assert_eq!(rust_index.edges().len(), 1); + assert_eq!(rust_index.edges()[0].arg_param_mappings().len(), 3); + assert_eq!(rust_index.edges()[0].mapping_unknown(), None); + + let go = r#" +func helper(condition bool, values map[string]int) {} +func caller(count int) { + helper(count == 0, map[string]int{"value": count}) +} +"#; + let go_index = build(Path::new("src/lib.go"), go, Lang::Go, (3, 5)); + assert_eq!(go_index.edges().len(), 1); + assert_eq!(go_index.edges()[0].arg_param_mappings().len(), 2); + assert_eq!(go_index.edges()[0].mapping_unknown(), None); +} + #[test] fn ambiguous_and_self_recursive_targets_abstain_from_edges() { let ambiguous = r#" From 03d83578c89c7590a34593cde31c91c32f8c3406 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 17:01:20 +0700 Subject: [PATCH 7/9] feat(cli): raise default output budget --- src/cli.rs | 6 ++++-- src/cli_run.rs | 31 +++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ecc0678..d3ecae5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,8 @@ use clap::{ArgAction, Args, Parser, ValueEnum}; use clap_complete::Shell; use srcwalk::ArtifactMode; +pub(crate) const DEFAULT_OUTPUT_BUDGET: u64 = 6_000; + /// srcwalk — Tree-sitter indexed lookups, smart code reading for AI agents. /// Run `srcwalk guide` for the embedded, version-matched agent guide. #[derive(Parser)] @@ -24,7 +26,7 @@ pub(crate) struct Cli { pub(crate) section: Option, /// Max tokens in response. Reduces detail to fit. - /// Default: 5000 unless --no-budget is set. + /// Default: 6000 unless --no-budget is set. #[arg(long)] pub(crate) budget: Option, @@ -330,7 +332,7 @@ pub(crate) struct CommonArgs { /// Scope root for search and relative path resolution. #[arg(long, default_value = ".", action = ArgAction::Append)] pub(crate) scope: Vec, - /// Max tokens in response. Reduces detail to fit. + /// Max tokens in response. Reduces detail to fit. Default: 6000. #[arg(long)] pub(crate) budget: Option, /// Disable default budget cap. diff --git a/src/cli_run.rs b/src/cli_run.rs index 47cff7d..4c222e7 100644 --- a/src/cli_run.rs +++ b/src/cli_run.rs @@ -329,14 +329,18 @@ fn canonicalize_scopes_or_exit( } } -pub(crate) fn run(mut config: RunConfig) { - // Effective budget: explicit --budget wins, --no-budget disables, - // otherwise default to 5000 tokens for deterministic agent/script output. - let effective_budget = if config.no_budget { +fn resolve_output_budget(configured: Option, no_budget: bool) -> Option { + if no_budget { None } else { - config.budget.or(Some(5_000)) - }; + configured.or(Some(crate::cli::DEFAULT_OUTPUT_BUDGET)) + } +} + +pub(crate) fn run(mut config: RunConfig) { + // Explicit --budget wins, --no-budget disables the cap, and all other + // commands share one deterministic default. + let effective_budget = resolve_output_budget(config.budget, config.no_budget); let cache = srcwalk::cache::OutlineCache::new(); let normalized_scopes = @@ -889,3 +893,18 @@ fn run_show( outputs.join("\n\n---\n\n") )) } + +#[cfg(test)] +mod tests { + use super::resolve_output_budget; + use crate::cli::DEFAULT_OUTPUT_BUDGET; + + #[test] + fn output_budget_resolution_preserves_default_and_overrides() { + assert_eq!(DEFAULT_OUTPUT_BUDGET, 6_000); + assert_eq!(resolve_output_budget(None, false), Some(6_000)); + assert_eq!(resolve_output_budget(Some(1_234), false), Some(1_234)); + assert_eq!(resolve_output_budget(None, true), None); + assert_eq!(resolve_output_budget(Some(1_234), true), None); + } +} From 34920d4d177d08e0431eec121ef25faa1b8c3e07 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 17:01:20 +0700 Subject: [PATCH 8/9] docs: condense README command examples --- README.md | 65 ++++++++++--------------------------------------------- 1 file changed, 12 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 4334b01..68a6c83 100644 --- a/README.md +++ b/README.md @@ -81,66 +81,25 @@ See [`CHANGELOG.md`](./CHANGELOG.md) for curated release notes. Maintainers shou ## Quick examples -Agent routing order lives in `srcwalk guide`; examples below are command reference, not a workflow. +These representative commands show the main shapes. Use `srcwalk --help` for the +full command and flag reference; agent routing lives in `srcwalk guide`. ```sh -# Version / installed binary check -srcwalk --version -srcwalk version --check - -# Read a file (structural view by default; raw pages are explicit) +# Read and drill into source srcwalk src/auth.ts -srcwalk src/auth.ts:72 # drill into exact hit line -srcwalk src/auth.ts --section handleAuth # drill into symbol -srcwalk src/auth.ts --section 72 # focused line context -srcwalk src/auth.ts --section 44-89 # line range - -# Discover definitions/usages/text/name globs/files -srcwalk discover handleAuth --scope src/ # definitions + usages -srcwalk discover handleAuth --scope src/auth.ts # exact-file scope -srcwalk discover handleAuth --scope 'src/**/*.ts' # glob scope -srcwalk discover handleAuth --scope src/auth.ts:40-90 # exact-file range scope -srcwalk discover "foo, bar" --scope src/ --scope tests/ # multi-symbol + multi-scope -srcwalk discover is_args --as access --scope src/ # file-grouped field/member access -srcwalk discover '*Controller' --as symbol --scope src/ --filter kind:class -srcwalk discover handleAuth --scope src/ --expand # inline source context -srcwalk discover '*.ts' --scope src/ # file globs are inferred -srcwalk discover handleAuth --scope src/ --exclude '*test*' # exclude file patterns -srcwalk discover 'alloc,copy' --match any --as text --scope src/ # literal text OR -srcwalk discover 'alloc,copy' --match all --as text --scope src/ # same-file co-occurrence -# Raw regex grep remains an rg job; srcwalk text discovery is literal navigation evidence. - -# Trace callers (reverse call graph) -srcwalk trace callers handleAuth --scope src/ -srcwalk trace callers decompileFunction --filter 'args:3' --scope src/ -srcwalk trace callers handleAuth --count-by caller --scope src/ # grouped compact output - -# Trace callees (forward call graph) -srcwalk trace callees handleAuth --scope src/ -srcwalk trace callees handleAuth --detailed --filter 'callee:validateToken' --scope src/ -srcwalk trace callees handleAuth --depth 2 --scope src/ # transitive - -# Context and Review Packets with Flow Maps -srcwalk context src/auth.ts:handleAuth # one-target Flow Map + neighborhood + Next footer -srcwalk review --staged # staged change Review Packet -srcwalk review HEAD~1..HEAD --scope src # changed evidence + changed-symbol Flow Maps +srcwalk src/auth.ts:72 +srcwalk src/auth.ts --section handleAuth -# Compare two known targets structurally -srcwalk compare src/auth.ts:validateToken src/auth.ts:validateSession - -# Context (compact slice: ordered calls + local resolves + callers) -srcwalk context handleAuth --filter 'callee:validateToken' --scope src/ - -# Assess (heuristic blast-radius triage) -srcwalk assess validateToken --scope src/ +# Find and follow code +srcwalk discover handleAuth --scope src/ +srcwalk context src/auth.ts:handleAuth +srcwalk trace callers handleAuth --scope src/ +srcwalk trace callees handleAuth --detailed --scope src/ -# Deps (file coupling) +# Review changes and orient in a project +srcwalk review --staged srcwalk deps src/auth.ts -srcwalk deps docs/guide.md # Markdown/HTML links and assets - -# 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. From 89dc3779a2766ff7c498673397ab959c11f1cd39 Mon Sep 17 00:00:00 2001 From: sting8k Date: Mon, 13 Jul 2026 17:01:20 +0700 Subject: [PATCH 9/9] chore: prepare v1.1.0 release --- CHANGELOG.md | 3 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- npm/package.json | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90ed81..7aaa983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ All notable changes to srcwalk are documented here. ## Unreleased +## [1.1.0] - 2026-07-13 + ### Added - Added bounded local structural links to context call neighborhoods and bounded unique direct-call evidence with positional argument-to-parameter mappings to context and `trace callees --detailed`. Evidence uses exact source anchors and abstains when targets or local predecessors are ambiguous. ### Changed - Aligned CLI error and overflow wording with command behavior, including unresolved callee labels and discover mode errors. +- Raised the default response budget from 5,000 to 6,000 tokens; explicit `--budget`, `--no-budget`, raw-read, overview, and evidence-row caps are unchanged. ## [1.0.1] - 2026-05-26 diff --git a/Cargo.lock b/Cargo.lock index dde0785..12913da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,7 +672,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "srcwalk" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aho-corasick", "clap", diff --git a/Cargo.toml b/Cargo.toml index 18b4608..74e3b6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "srcwalk" -version = "1.0.1" +version = "1.1.0" edition = "2021" description = "Tree-sitter indexed lookups — smart code reading for AI agents" license = "MIT" diff --git a/npm/package.json b/npm/package.json index 708b527..035d316 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "srcwalk", - "version": "1.0.1", + "version": "1.1.0", "description": "Code-intelligence CLI for AI agents — tree-sitter outlines, symbol search, caller/callee graphs, deps, overview", "bin": { "srcwalk": "run.js"