Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to srcwalk are documented here.

## [1.8.1] - 2026-09-06

### Fixed
- Emitted generic selectors carrying a comma (`show 'src/cache.rs:Cache<K, V>.get'`) now replay unchanged through `show`, `context`, `trace callers`, `trace callees`, and `--section`. Target lists split only at a comma outside balanced `<...>`, so such a selector stays one target and can be combined with other targets, while an unbalanced comma-separated target list fails once as an explicit framing error without running any target. Comma-free targets, target lists, and every discover/query comma grammar are unchanged.
- Symbol batches (`discover 'a,b' --as symbol`) no longer drop definitions that the same terms find on their own. The batch definition prefilter now derives its scan needle exactly like the single-symbol search, so a receiver/container-qualified term keeps its method definition inside a batch (`'Batch.Set,helper'`), and it matches overlapping needles, so a shorter term no longer masks a longer one (`'helper,helper_extra'`). Qualification is still decided by the existing structural matcher, usage matching is unchanged, and single-symbol output stays byte-for-byte identical.

## [1.8.0] - 2026-08-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "srcwalk"
version = "1.8.0"
version = "1.8.1"
edition = "2021"
description = "Tree-sitter indexed lookups — smart code reading for AI agents"
license = "MIT"
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ srcwalk overview --scope src/

Discovery commands respect ignore files; explicit file reads can still inspect ignored paths.

An exact `<path>:<symbol>` target that srcwalk prints is reusable verbatim across `show`, `context`, `trace callers`, and `trace callees`. A comma inside a generic selector belongs to that one target, while a comma outside `<...>` separates targets in a list:

```sh
srcwalk show 'src/cache.rs:Cache<K, V>.get' # one target
srcwalk show 'src/cache.rs:Cache<K, V>.get,src/auth.ts:handleAuth' # two targets
```

Regex-dialect and path-fragment `discover` queries are translated instead of dead-ending (no regex engine runs):
- `srcwalk discover 'parseGitUrl\(' --scope src/` de-escapes to literal + symbol search, labeled `interpreted as`.
- `srcwalk discover 'a.*b' --scope src/` runs bounded same-line ordered co-occurrence of `a` then `b`.
Expand Down Expand Up @@ -348,6 +355,8 @@ hunks:
<details>
<summary><b>Discover — multi-symbol and multi-scope</b></summary>

Symbol batches take 2-5 comma-separated terms and report one section per term. A receiver/container-qualified term keeps the definition semantics it has as a single query, so `srcwalk discover 'NextAction.new,render_next_actions' --as symbol --scope src/evidence` returns the `NextAction.new` method definition rather than name occurrences only.

```
$ 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 name occurrences)
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "srcwalk",
"version": "1.8.0",
"version": "1.8.1",
"description": "Code-intelligence CLI for AI agents — tree-sitter outlines, symbol search, caller/callee graphs, deps, overview",
"bin": {
"srcwalk": "run.js"
Expand Down
4 changes: 2 additions & 2 deletions skills/srcwalk/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Turn the request's explicit evidence questions into a short coverage list. In ea
- Multi-root symbol discovery may repeat the flag: `srcwalk discover 'foo,bar' --as symbol --scope src --scope tests`.
- Other routes and `discover --as text|file|access` accept one scope; use a common ancestor or run independent commands in the same model/tool round.
- Keep scope as small as the evidence question allows; narrow scopes can hide definitions.
- Symbol batches accept 2-5 comma-separated symbols: `srcwalk discover 'foo,bar,baz' --as symbol --scope src`. Split larger symbol sets. One dot qualifies a method by receiver/container in a single-symbol query (`'Batch.Set'`); dotted terms inside a batch are a known matching limitation, so run that dotted term separately when exact qualification is required.
- Symbol batches accept 2-5 comma-separated symbols: `srcwalk discover 'foo,bar,baz' --as symbol --scope src`. Split larger symbol sets. One dot qualifies a method by receiver/container (`'Batch.Set'`), and a qualified term keeps its single-query definition semantics inside a batch: `srcwalk discover 'Batch.Set,helper' --as symbol --scope src`.
- Text OR is separate: `srcwalk discover 'alloc,copy' --match any --as text --scope src` is literal text evidence, not a symbol batch.
- Do not infer definitions, usages, callers, deps, or code paths from shell path lists, broad grep, or converted identifier paths.

Expand Down Expand Up @@ -66,7 +66,7 @@ Use auto overview depth first; explicit `--depth N` is strict. Narrow `overview

Intent inference: path-like globs infer file discovery; punctuation/path comma lists infer literal Text OR; symbol globs stay symbol search. Add `--as symbol|file|text|access` when ambiguous. After a first pass, use `--expand=3`, `--filter kind:fn`, or `--exclude 'tests/**'` only when output is too broad. Regex-style queries are translated, not executed as regex: `foo\(` de-escapes to literal + symbol search, `a.*b` runs same-line ordered co-occurrence, `models\.json` behaves like `models.json`, and an unresolved path fragment like `packages/ai` lists matching relative paths (≤20). Each translation is labeled `interpreted as`; zero-match branches print a `> Try:` recovery line. Windows drive paths and `./`/`../` paths are never treated as regex. Text Search and Text OR may add a conditional `> Note:` when a term has at least 400 matches across at least 150 files and reaches at least 1.5% of eligible files; treat it as measured spread, then consider `overview`, a narrower term or scope, or a structural route when that spread was not intentional.

If `discover` prints `## Confirmed structural targets`, run the printed `> Next:` command verbatim. The target is a canonical `<path>:<symbol>` built from parser outline primitives and proven to resolve to that one body; a numeric `<path>:<start-end>` command is the safe fallback when ambiguity prevents a unique symbol selector. A numeric range shown beside a stable symbol is evidence metadata (a bounded preview), not the preferred body address. Use `srcwalk context <target>` only when you need a Flow Map, scoped occurrences, or call neighborhood; do not run `context` for each hop just to read source. Reuse that `<path>:<symbol>` string unchanged across `show`, `context`, `trace callers`, and `trace callees`, keeping any `--scope` printed with it; never rebuild it from a displayed qualified name, because a namespace/module display prefix (`System.Text.Json.GetTypeInfoInternal`) is not a selector and the emitted form uses the owning container (`JsonSerializerOptions.GetTypeInfoInternal`). The path identifies the requested definition; it does not upgrade caller evidence — `trace callers` stays a direct by-name search and may include same-name definitions elsewhere. Unresolved or ambiguous targets fail explicitly instead of falling back to a bare-name search.
If `discover` prints `## Confirmed structural targets`, run the printed `> Next:` command verbatim. The target is a canonical `<path>:<symbol>` built from parser outline primitives and proven to resolve to that one body; a numeric `<path>:<start-end>` command is the safe fallback when ambiguity prevents a unique symbol selector. A numeric range shown beside a stable symbol is evidence metadata (a bounded preview), not the preferred body address. Use `srcwalk context <target>` only when you need a Flow Map, scoped occurrences, or call neighborhood; do not run `context` for each hop just to read source. Reuse that `<path>:<symbol>` string unchanged across `show`, `context`, `trace callers`, and `trace callees`, keeping any `--scope` printed with it; never rebuild it from a displayed qualified name, because a namespace/module display prefix (`System.Text.Json.GetTypeInfoInternal`) is not a selector and the emitted form uses the owning container (`JsonSerializerOptions.GetTypeInfoInternal`). The path identifies the requested definition; it does not upgrade caller evidence — `trace callers` stays a direct by-name search and may include same-name definitions elsewhere. A comma inside a generic selector (`Cache<K, V>.get`) is part of that one target, so copy the quoted command unchanged; only a comma outside `<...>` separates targets in a `show`/`context` list. Unresolved or ambiguous targets fail explicitly instead of falling back to a bare-name search.

Symbol discovery separates parser-backed definitions from text-matched name occurrences. Repeated same-name definitions receive an ambiguity caveat. Text discovery remains literal evidence; `--match all` is same-file co-occurrence, not semantic relation proof.

Expand Down
46 changes: 45 additions & 1 deletion src/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,24 @@ pub(crate) fn should_auto_artifact_file(path: &Path) -> bool {
crate::search::io::looks_minified(&bytes)
}

/// Section addresses this single-section reader cannot serve: a heading, a line
/// range, or a real target list.
///
/// A comma nested in a generic selector (`TsCache<K, V>.get`) belongs to one
/// symbol, so only a depth-zero comma marks a list. Malformed input is left to
/// the shared section framing, which rejects it before any body is read.
fn is_unsupported_section_address(symbol: &str) -> bool {
symbol.starts_with('#')
|| crate::format::split_target_list(symbol).is_ok_and(|framed| framed.len() > 1)
|| parse_line_range(symbol).is_some()
}

pub(crate) fn read_js_ts_symbol_section(
path: &Path,
symbol: &str,
budget: Option<u64>,
) -> Option<Result<String, SrcwalkError>> {
if symbol.starts_with('#') || symbol.contains(',') || parse_line_range(symbol).is_some() {
if is_unsupported_section_address(symbol) {
return None;
}

Expand Down Expand Up @@ -591,3 +603,35 @@ fn clean_export_name(text: &str) -> Option<String> {
}
Some(name)
}

#[cfg(test)]
mod section_address_tests {
use super::is_unsupported_section_address;

/// US-076: the single-section artifact reader must classify a nested-comma
/// selector as one symbol and keep refusing a real target list.
#[test]
fn nested_generic_comma_is_one_symbol_but_a_target_list_is_refused() {
for one_symbol in [
"TsCache<K, V>.get",
"Outer<K, Inner<V, u8>>.deep",
"TsCache.get",
] {
assert!(
!is_unsupported_section_address(one_symbol),
"{one_symbol} must reach symbol lookup"
);
}

for unsupported in ["a,b", "TsCache<K, V>.get,other", "#heading", "10-20"] {
assert!(
is_unsupported_section_address(unsupported),
"{unsupported} must not reach symbol lookup"
);
}

// Malformed input is deliberately not classified here: the shared
// section framing rejects it before any body is read.
assert!(!is_unsupported_section_address("TsCache<K, V.get"));
}
}
25 changes: 20 additions & 5 deletions src/cli_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,13 +865,20 @@ fn run_context(
filter: Option<&str>,
artifact: ArtifactMode,
) -> Result<String, srcwalk::error::SrcwalkError> {
if !target.contains(',') {
let raw_targets = srcwalk::format::split_target_list(target).map_err(|reason| {
srcwalk::error::SrcwalkError::InvalidQuery {
query: target.to_string(),
reason: reason.to_string(),
}
})?;
// One framed target covers both a comma-free target and a selector whose
// only commas are nested in generics.
if raw_targets.len() == 1 {
return srcwalk::run_flow_with_artifact(
target, scope, budget, cache, depth, filter, artifact,
);
}

let raw_targets: Vec<&str> = target.split(',').collect();
if raw_targets.iter().any(|part| part.trim().is_empty()) {
return Err(srcwalk::error::SrcwalkError::InvalidQuery {
query: target.to_string(),
Expand Down Expand Up @@ -1222,7 +1229,15 @@ fn run_show(
context_lines: Option<usize>,
cache: &srcwalk::cache::OutlineCache,
) -> Result<String, srcwalk::error::SrcwalkError> {
if !target.contains(',') {
let framed = srcwalk::format::split_target_list(target).map_err(|reason| {
srcwalk::error::SrcwalkError::InvalidQuery {
query: target.to_string(),
reason: reason.to_string(),
}
})?;
// One framed target covers both a comma-free target and a selector whose
// only commas are nested in generics.
if framed.len() == 1 {
return srcwalk::run_path_exact_with_artifact_and_context(
target,
scope,
Expand All @@ -1243,8 +1258,8 @@ fn run_show(
});
}

let targets: Vec<&str> = target
.split(',')
let targets: Vec<&str> = framed
.into_iter()
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
Expand Down
119 changes: 119 additions & 0 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,55 @@ fn is_shell_safe_path_char(c: char) -> bool {
|| cfg!(windows) && c == '\\'
}

/// Generic angle brackets in a comma-separated target list are unbalanced, so
/// the list cannot be framed without guessing a repair.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnbalancedAngleBrackets;

impl std::fmt::Display for UnbalancedAngleBrackets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("unbalanced `<...>` in comma-separated target list")
}
}

/// Frame a comma-separated list of exact targets.
///
/// A comma inside balanced generic angle brackets is selector data, not a
/// separator: `cache.rs:Cache<K, V>.get` is one target, while
/// `cache.rs:Cache<K, V>.get,a.rs:A.run` is two. Slices are returned exactly as
/// written, so each consumer keeps its own trimming and empty-item policy.
///
/// Input without a comma is one target and is returned without bracket
/// validation: there is no list to frame, so no input that parses today can
/// start failing here.
pub fn split_target_list(input: &str) -> Result<Vec<&str>, UnbalancedAngleBrackets> {
if !input.contains(',') {
return Ok(vec![input]);
}

let mut targets = Vec::new();
let mut depth: usize = 0;
let mut start = 0;
// ASCII `<`, `>` and `,` never occur inside a multi-byte UTF-8 sequence, so
// byte offsets are safe slice boundaries.
for (offset, byte) in input.bytes().enumerate() {
match byte {
b'<' => depth += 1,
b'>' => depth = depth.checked_sub(1).ok_or(UnbalancedAngleBrackets)?,
b',' if depth == 0 => {
targets.push(&input[start..offset]);
start = offset + 1;
}
_ => {}
}
}
if depth != 0 {
return Err(UnbalancedAngleBrackets);
}
targets.push(&input[start..]);
Ok(targets)
}

/// Split trailing footer guidance from primary output.
#[must_use]
pub fn split_trailing_footer(output: &str) -> Option<(&str, &str)> {
Expand Down Expand Up @@ -268,4 +317,74 @@ mod tests {
"2 matches (1 definitions, 1 text matches)"
);
}

#[test]
fn split_target_list_keeps_nested_generic_commas_in_one_target() {
assert_eq!(
split_target_list("cache.rs:Cache<K, V>.get"),
Ok(vec!["cache.rs:Cache<K, V>.get"])
);
assert_eq!(
split_target_list("cache.rs:Outer<K, Inner<V, W>>.get"),
Ok(vec!["cache.rs:Outer<K, Inner<V, W>>.get"])
);
}

#[test]
fn split_target_list_splits_only_at_depth_zero_commas() {
assert_eq!(
split_target_list("a.rs:A.run,b.rs:B.run"),
Ok(vec!["a.rs:A.run", "b.rs:B.run"])
);
assert_eq!(
split_target_list("cache.rs:Cache<K, V>.get,a.rs:A.run"),
Ok(vec!["cache.rs:Cache<K, V>.get", "a.rs:A.run"])
);
assert_eq!(
split_target_list("a.rs:A.run,cache.rs:Outer<K, Inner<V, W>>.get"),
Ok(vec!["a.rs:A.run", "cache.rs:Outer<K, Inner<V, W>>.get"])
);
}

#[test]
fn split_target_list_preserves_slices_order_and_duplicates() {
// Spacing and empty items survive verbatim; consumers own that policy.
assert_eq!(
split_target_list("a.rs:A.run, a.rs:A.run,,b.rs:B.run "),
Ok(vec!["a.rs:A.run", " a.rs:A.run", "", "b.rs:B.run "])
);
// A comma path still frames as separate depth-zero pieces; canonical
// emission keeps using the path + `--section` form for those.
assert_eq!(
split_target_list("a,file.rs:run"),
Ok(vec!["a", "file.rs:run"])
);
}

#[test]
fn split_target_list_rejects_unbalanced_angle_brackets() {
assert_eq!(
split_target_list("cache.rs:Cache<K, V.get,a.rs:A.run"),
Err(UnbalancedAngleBrackets)
);
assert_eq!(
split_target_list("cache.rs:Cache K, V>.get,a.rs:A.run"),
Err(UnbalancedAngleBrackets)
);
assert_eq!(
UnbalancedAngleBrackets.to_string(),
"unbalanced `<...>` in comma-separated target list"
);
}

#[test]
fn split_target_list_returns_comma_free_input_unvalidated() {
// No comma means no list to frame, so today's inputs cannot start failing.
assert_eq!(split_target_list("a.rs:A.run"), Ok(vec!["a.rs:A.run"]));
assert_eq!(
split_target_list("weird>name.rs:run"),
Ok(vec!["weird>name.rs:run"])
);
assert_eq!(split_target_list(""), Ok(vec![""]));
}
}
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ fn normalize_show_target_group(value: &OsStr) -> Option<Vec<String>> {
let mut normalized = Vec::new();
let mut shorthand_path = None;

for part in value.split(',').map(str::trim) {
// Same target-list framing as `show` itself, so a hint never rewrites a
// generic selector into fragments.
let framed = srcwalk::format::split_target_list(value).ok()?;
for part in framed.into_iter().map(str::trim) {
if part.is_empty() {
return None;
}
Expand Down
Loading
Loading