From 28930e09b1cd25165f1f9ffb2758a9b22e7fbb8e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:05:40 +0200 Subject: [PATCH] gather: deterministic context-pack tool (aft_gather_context) Normalize path separators before comparison so absolute and relative spellings of the same file dedupe on Windows. Path::is_absolute() is false for rooted-no-drive paths there, and strip_prefix emits backslashes, so the same symbol previously rendered twice in one pack. Backslash is a legal filename character on Unix, so a file literally named 'a\b.rs' now normalizes to 'a/b.rs' and renders a not-found stub. Accepted: matches normalize_path_for_compare, fails gracefully. --- ARCHITECTURE.md | 12 +- README.md | 1 + STRUCTURE.md | 4 +- crates/aft/src/commands/gather.rs | 1665 +++++++++++++++++ crates/aft/src/commands/mod.rs | 1 + crates/aft/src/main.rs | 1 + crates/aft/src/subc/manifest.rs | 7 +- crates/aft/src/subc_tool_schemas.json | 29 + crates/aft/src/subc_translate.rs | 191 ++ crates/aft/tests/integration/gather_test.rs | 115 ++ crates/aft/tests/integration/main.rs | 1 + docs/v0.49-agent-surface-manifest.json | 56 +- docs/v0.49-legacy-vocabulary-allowlist.json | 316 +++- .../v0.49-unified-tool-surface-inventory.json | 8 +- .../__tests__/subc-tool-schemas-fresh.test.ts | 2 +- .../tool-surface-transport-invariant.test.ts | 46 + .../opencode-plugin/src/subc-tool-schemas.ts | 5 + .../opencode-plugin/src/tool-registration.ts | 10 +- packages/opencode-plugin/src/tools/gather.ts | 93 + 19 files changed, 2464 insertions(+), 99 deletions(-) create mode 100644 crates/aft/src/commands/gather.rs create mode 100644 crates/aft/tests/integration/gather_test.rs create mode 100644 packages/opencode-plugin/src/tools/gather.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6300aad4..6d8a68a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ **Protocol and command layer:** - Purpose: Accept NDJSON requests, route tool calls via the unified `tool_call` command, and dispatch them to focused command handlers. - Location: `crates/aft/src/main.rs`, `crates/aft/src/protocol.rs`, `crates/aft/src/commands/`, `crates/aft/src/run_tool_call.rs`, `crates/aft/src/runtime_drain.rs`, `crates/aft/src/subc_translate.rs`, `crates/aft/src/subc_format.rs` -- Contains: Request dispatch, response encoding, a unified `tool_call` routing engine, tool-to-command translation mapping, server-rendered agent-facing text formatting (with directory outlines formatted as text unwrapping JSON envelopes), non-blocking control channel 0 health check responder reading derivation maps without spawning git subprocesses, and standalone command handlers for read/write/edit/hashline/apply_patch/delete_file/move_file/outline/zoom/bash/bash_orchestrate/bash_status/bash_wait_detach/batch/grep/glob/search/imports/refactor/LSP/inspect/conflicts/checkpoints/state +- Contains: Request dispatch, response encoding, a unified `tool_call` routing engine, tool-to-command translation mapping, server-rendered agent-facing text formatting (with directory outlines formatted as text unwrapping JSON envelopes), non-blocking control channel 0 health check responder reading derivation maps without spawning git subprocesses, and standalone command handlers for read/write/edit/hashline/apply_patch/delete_file/move_file/outline/zoom/bash/bash_orchestrate/bash_status/bash_wait_detach/batch/grep/glob/search/gather/imports/refactor/LSP/inspect/conflicts/checkpoints/state - Depends on: `crates/aft/src/context.rs`, `crates/aft/src/parser.rs`, `crates/aft/src/callgraph.rs`, `crates/aft/src/callgraph_store/mod.rs`, `crates/aft/src/edit.rs`, `crates/aft/src/semantic_index.rs`, `crates/aft/src/search_index.rs`, `crates/aft/src/compress/` - Used by: `packages/aft-bridge/src/bridge.ts` @@ -110,6 +110,14 @@ 3. Classify query shape (prose vs code) using the query shape parser -- `crates/aft/src/query_shape.rs`. Identify "type-concept identifier queries" (TitleCase PascalCase types combined with lowercase concepts) to trigger definition semantic priors. 4. Serve `grep` (trigram, full-text) and `aft_search` (semantic + hybrid) queries, delegating to `GrepExecutor` for accelerated path evaluation and enforcing execution safety limits (like `MAX_FALLBACK_WALK_FILES` and `FALLBACK_WALK_BUDGET`) during fallback walks when indexes are building or unavailable -- `crates/aft/src/grep_executor.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`. Interactive query embeddings and search artifact waits are bounded by dedicated budgets (`QueryBudget` and bounded interactive search artifact wait timeouts; `query_timeout_ms` clamped to 500..15000ms, defaulting to 3000ms) to keep interactive requests fast without affecting background build/refresh timeouts, falling back to lexical search if query embedding fails or times out. Downrank generated documentation artifacts (e.g. minified CSS/JS, maps, SVGs) in lexical and hybrid search results. For external search requests, resolve and cache external git roots, querying cached read-only search and semantic indexes from the `borrowed_index_cache` (capped at 4 concurrent entries) to avoid redundant git probes and disk parsing. +**Context-pack gather flow:** + +1. Accept one of two mutually exclusive modes -- `crates/aft/src/commands/gather.rs`, `crates/aft/src/subc_translate.rs::translate_gather`. In question mode, seed with a natural-language query (semantic search). In agent-facing symbol mode, seed with a `(symbol, path)` pair (callgraph impact: depth-1 callers via `impact` + depth-1 callees via `call_tree`). The plugin maps `path` to the internal `filePath` wire key before translation. Mode validation rejects both or neither with `invalid_request`, and the OpenCode plugin enforces the same XOR at the schema boundary in `packages/opencode-plugin/src/tools/gather.ts`. +2. Resolve seeds and 1-hop neighbors from the persisted indexes -- `crates/aft/src/callgraph_store/mod.rs`, `crates/aft/src/commands/semantic_search.rs`. Question mode routes through `handle_semantic_search` (the same pipeline as `aft_search`), so hybrid semantic + lexical scoring and the `query_timeout_ms` budget apply. Symbol mode uses `impact` and `call_tree` against the SQLite store, dropping paths outside the project root via `pending_path_in_roots` containment. Unresolved external callees collapse to a single summary line; seeds and unresolved callers are never suppressed. +3. Dedupe candidates by canonicalized `(file, symbol)` (project-root-prefixed absolute paths merged with relative hits) -- `crates/aft/src/commands/gather.rs::dedup_by_file_and_name`. Seeds win on conflict; the same canonical symbol reached through two routes is rendered once. +4. Render each candidate's symbol body within a hard line budget (default 400, cap 800) via `render_symbol_within_budget` -- `crates/aft/src/commands/gather.rs::render_symbol_section`, `crates/aft/src/commands/symbol_render.rs`. Grep-fallback hits resolve to their containing symbol by line containment (`resolve_containing_symbol`); hits with no containing symbol stay visible one-line stubs so nothing is silently dropped. Over-budget candidates are emitted under `## Beyond budget (zoom to expand)` as stubs rather than truncated, so the agent can see what was excluded. +5. Flag index degradation honestly in the pack header. A `semantic_status: "building"` field surfaces when the semantic index is still building AND no symbol-mode seed resolved -- suppressed the moment any symbol seed provides a real anchor. Resolve relative paths against `project_root` to keep `file:line` headers stable across worktree mounts. Follow the tri-state honest-reporting convention (`crates/aft/src/protocol.rs` `Response` doc-comment) so the agent can distinguish empty scope, partial pack, and complete pack without guessing. + **File read flow:** 1. Map read arguments and validate boundary permissions -- `packages/opencode-plugin/src/tools/reading.ts`, `packages/pi-plugin/src/tools/reading.ts`. Under project-root path restriction, allow restricted reading of files outside the project root if they are session-owned bash artifact outputs (stdout, stderr, exit code, or pty outputs) registered under the requesting session ID (validated via `AppContext::validate_read_path` using `BgTaskRegistry::is_session_owned_artifact_path`), while strictly rejecting any mutations (which continue to enforce project root boundaries via `AppContext::validate_path`). The plugin skips the external-directory permission prompt for session-owned bash task artifacts under the AFT storage root when performing server-validated reads, avoiding hangs in unattended runs. @@ -196,7 +204,7 @@ **Tool groups (OpenCode):** - Purpose: Group related OpenCode tool definitions by capability surface. -- Location: `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/imports.ts`, `packages/opencode-plugin/src/tools/navigation.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/safety.ts`, `packages/opencode-plugin/src/tools/conflicts.ts`, `packages/opencode-plugin/src/tools/ast.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/bash_watch.ts`, `packages/opencode-plugin/src/tools/bash_write.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts`, `packages/opencode-plugin/src/tools/semantic.ts`, `packages/opencode-plugin/src/tools/permissions.ts`, `packages/opencode-plugin/src/tools/hoisted-internals.ts` +- Location: `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/imports.ts`, `packages/opencode-plugin/src/tools/navigation.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/safety.ts`, `packages/opencode-plugin/src/tools/conflicts.ts`, `packages/opencode-plugin/src/tools/ast.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/bash_watch.ts`, `packages/opencode-plugin/src/tools/bash_write.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts`, `packages/opencode-plugin/src/tools/semantic.ts`, `packages/opencode-plugin/src/tools/gather.ts`, `packages/opencode-plugin/src/tools/permissions.ts`, `packages/opencode-plugin/src/tools/hoisted-internals.ts` - Pattern: Thin TypeScript adapters delegating to the unified `tool_call` transport **Tool groups (Pi):** diff --git a/README.md b/README.md index 9a2d59e7..3267a8e8 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ AFT is **1 of the 3 plugins you'll ever need.** It perceives and acts; Magic Con - **`aft_search`**: find code by *meaning* when grep keywords fall short. Hybrid semantic + lexical retrieval over an indexed codebase, with local, OpenAI-compatible, or Ollama embedding backends. - **`aft_callgraph`**: follow callers, callees, data flow, impact analysis, and the shortest call path between two symbols across the workspace. - **`aft_inspect`**: a one-call codebase-health report covering LSP errors and warnings, TODOs, metrics, dead code, unused exports, and duplicates. The Problems and inspections panels an IDE keeps open, on demand. + - **`aft_gather_context`**: assemble a bounded context pack — ranked, deduped, budgeted verbatim code evidence — in one call instead of a serial `search → outline → zoom → callgraph` chain. Seed from a `question` (semantic) or a `symbol`+`path` (callgraph), expand one hop, render within a hard line budget. - **`grep` / `glob`**: trigram-indexed regex search and file discovery, built in the background, persisted to disk, and kept fresh by a file watcher. --- diff --git a/STRUCTURE.md b/STRUCTURE.md index bf1936e6..2cc430fb 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -50,7 +50,7 @@ opencode-aft/ **`crates/aft/src/commands/`:** - Purpose: Add one handler file per protocol command. - Contains: ~60 command-specific request parsing and response generation modules -- Key files: `crates/aft/src/commands/tool_call.rs`, `crates/aft/src/commands/read.rs`, `crates/aft/src/commands/write.rs`, `crates/aft/src/commands/hashline.rs`, `crates/aft/src/commands/apply_patch.rs`, `crates/aft/src/commands/bash_orchestrate.rs`, `crates/aft/src/commands/bash_wait_detach.rs`, `crates/aft/src/commands/outline.rs`, `crates/aft/src/commands/zoom.rs`, `crates/aft/src/commands/bash.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`, `crates/aft/src/commands/configure.rs` +- Key files: `crates/aft/src/commands/tool_call.rs`, `crates/aft/src/commands/read.rs`, `crates/aft/src/commands/write.rs`, `crates/aft/src/commands/hashline.rs`, `crates/aft/src/commands/apply_patch.rs`, `crates/aft/src/commands/bash_orchestrate.rs`, `crates/aft/src/commands/bash_wait_detach.rs`, `crates/aft/src/commands/outline.rs`, `crates/aft/src/commands/zoom.rs`, `crates/aft/src/commands/gather.rs`, `crates/aft/src/commands/bash.rs`, `crates/aft/src/commands/grep.rs`, `crates/aft/src/commands/semantic_search.rs`, `crates/aft/src/commands/configure.rs` **`crates/aft/src/compress/`:** - Purpose: Provide tiered output compression for hoisted bash commands. @@ -124,7 +124,7 @@ opencode-aft/ **`packages/opencode-plugin/src/tools/`:** - Purpose: Group OpenCode tool definitions by capability area. -- Contains: Thin adapters for hoisted (advertising `filePath` on OpenCode for read/write/edit to honor host UI header display contract), reading, import, navigation, refactor, safety, bash, conflict, AST, search, semantic, and inspect tools; permissions and internals helpers +- Contains: Thin adapters for hoisted (advertising `filePath` on OpenCode for read/write/edit to honor host UI header display contract), reading, import, navigation, refactor, safety, bash, conflict, AST, search, semantic, gather, and inspect tools; permissions and internals helpers - Key files: `packages/opencode-plugin/src/tools/_shared.ts`, `packages/opencode-plugin/src/tools/hoisted.ts`, `packages/opencode-plugin/src/tools/reading.ts`, `packages/opencode-plugin/src/tools/refactoring.ts`, `packages/opencode-plugin/src/tools/bash.ts`, `packages/opencode-plugin/src/tools/inspect.ts`, `packages/opencode-plugin/src/tools/search.ts` **`packages/pi-plugin/`:** diff --git a/crates/aft/src/commands/gather.rs b/crates/aft/src/commands/gather.rs new file mode 100644 index 00000000..a7d37ba3 --- /dev/null +++ b/crates/aft/src/commands/gather.rs @@ -0,0 +1,1665 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::commands::callgraph_store_adapter::{ + building_response, call_tree_result, impact_result, store_error_response, unavailable_response, +}; +use crate::commands::semantic_search::handle_semantic_search; +use crate::commands::symbol_render::{ + render_symbol_within_budget, symbol_kind_string, BudgetedSymbolRenderStatus, +}; +use crate::context::{AppContext, CallgraphStoreAccess}; +use crate::grep_executor; +use crate::parser::detect_language; +use crate::protocol::{RawRequest, Response}; + +const DEFAULT_BUDGET: usize = 400; +const MAX_BUDGET: usize = 800; + +/// Render-error marker matched by callee-suppression guard in build_pack. +/// The guard suppresses unresolved external callees from the stub list; +/// this constant ties the producer (render_symbol_section) and consumer +/// (suppression check) so the literal cannot drift apart. +const UNRESOLVED_MARKER: &str = "(symbol not resolved)"; + +/// Provenance prefix for callee neighbors — used by the suppression guard +/// to scope unresolved suppression to external callees only (not seeds +/// or callers, which remain visible stubs). +const CALLEE_PROVENANCE_PREFIX: &str = "callee-of-"; + +/// Normalize a file path to a consistent form for deduplication. +/// Converts absolute paths to repo-relative by stripping the project root +/// prefix, so that `/home/user/project/src/a.rs` and `src/a.rs` match. +fn normalize_file_path(raw: &str, project_root: &Path) -> String { + let normalized_raw = raw.replace('\\', "/"); + let normalized_root = project_root.to_string_lossy().replace('\\', "/"); + let path = Path::new(&normalized_raw); + let root = Path::new(&normalized_root); + if let Ok(stripped) = path.strip_prefix(root) { + return stripped.to_string_lossy().replace('\\', "/"); + } + // Strip any leading `./` for consistent relative form. + normalized_raw.trim_start_matches("./").to_string() +} + +/// Resolve the symbol that contains a given line in a file. +/// Uses `list_symbols` to enumerate all symbols, then returns the one +/// whose range contains `line` (1-based). Returns `None` when no symbol +/// covers the line (comment, blank, import, etc.). +fn resolve_containing_symbol( + file_path: &Path, + line: u32, + ctx: &AppContext, +) -> Option<(String, u32)> { + if line == 0 { + return None; + } + let symbols = ctx.provider().list_symbols(file_path).ok()?; + let line_0b = line.saturating_sub(1); + // Prefer the innermost (smallest) containing symbol. + symbols + .iter() + .filter(|s| s.range.start_line <= line_0b && line_0b <= s.range.end_line) + .min_by_key(|s| { + // Smaller range = more specific (innermost). + s.range.end_line.saturating_sub(s.range.start_line) + }) + .map(|s| (s.name.clone(), s.range.start_line.saturating_add(1))) +} + +/// A candidate symbol to include in the pack. +#[derive(Debug, Clone)] +struct PackCandidate { + file: String, + name: String, + start_line: u32, + /// Where this candidate came from, for provenance in the pack output. + provenance: String, + /// Score for ranking (search score, or 0 for callgraph-derived). + score: f32, + /// Seed ordinal (0-based) if this is a seed; None for neighbors. + seed_ordinal: Option, + /// Hop distance from seed (0 for seeds, 1 for direct neighbors). Used for + /// interleaving: candidates are ordered by (seed_ordinal, hop_distance) so + /// a seed's body appears before its callers, then its callees. + #[allow(dead_code)] + hop_distance: u32, +} + +/// Handle a `gather` request — assemble a deterministic context pack. +pub fn handle_gather(req: &RawRequest, ctx: &AppContext) -> Response { + let question = req.params.get("question").and_then(|v| v.as_str()); + let symbol = req.params.get("symbol").and_then(|v| v.as_str()); + let file_path_str = req.params.get("filePath").and_then(|v| v.as_str()); + + // Modes are mutually exclusive. + let has_question = question.is_some(); + let has_symbol = symbol.is_some() && file_path_str.is_some(); + if has_question == has_symbol { + return Response::error( + &req.id, + "invalid_request", + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'filePath'", + ); + } + + let budget = req + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + let include_tests = req + .params + .get("includeTests") + .or_else(|| req.params.get("include_tests")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if has_question { + let q = question.unwrap(); + handle_gather_question(req, ctx, q, budget, include_tests) + } else { + let s = symbol.unwrap(); + let fp = file_path_str.unwrap(); + handle_gather_symbol(req, ctx, s, fp, budget, include_tests) + } +} + +fn handle_gather_question( + req: &RawRequest, + ctx: &AppContext, + question: &str, + budget: usize, + include_tests: bool, +) -> Response { + let search_req = RawRequest { + id: format!("{}_search", req.id), + command: "semantic_search".to_string(), + lsp_hints: req.lsp_hints.clone(), + session_id: req.session_id.clone(), + params: serde_json::json!({ + "query": question, + "top_k": 15, + "hint": "auto", + "include_tests": include_tests, + }), + }; + + let search_resp = handle_semantic_search(&search_req, ctx); + if !search_resp.success { + return Response::error( + &req.id, + "search_failed", + format!( + "aft_gather_context: search failed: {}", + serde_json::to_string(&search_resp.data).unwrap_or_default() + ), + ); + } + + let results = match search_resp.data.get("results").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => { + return Response::error( + &req.id, + "search_failed", + "aft_gather_context: search returned no results array", + ); + } + }; + + // Detect semantic-index degradation from the search response. + // When the index is building, NL queries fall back to lexical-only + // FileSummary results that carry no symbol names. + let semantic_status = search_resp + .data + .get("semantic_status") + .and_then(|v| v.as_str()) + .unwrap_or("ready"); + + let project_root = grep_executor::project_root(ctx); + + let mut seeds: Vec = Vec::new(); + let mut no_symbol_stubs: Vec = Vec::new(); + for result in results { + let raw_file = result.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let name_str = result.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let score = result.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; + let start_line = result + .get("start_line") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + // Handle grep-fallback results that carry a file+line but no symbol + // name. Resolve the containing symbol by line instead of extracting + // a name from the line text. + let (name, resolved_start_line) = if !name_str.is_empty() { + (name_str.to_string(), start_line) + } else { + // Resolve search-result paths against project_root so + // filesystem ops work regardless of process CWD. + let raw_file_path = if Path::new(raw_file).is_absolute() { + PathBuf::from(raw_file) + } else { + project_root.join(raw_file) + }; + let line = if start_line == 0 { + result.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32 + } else { + start_line + }; + if !raw_file_path.exists() || line == 0 { + // File doesn't exist or no line number — visible stub. + no_symbol_stubs.push(format!( + "{}:{} — {} (no containing symbol)", + raw_file, + line.max(1), + format!("search score={:.3}", score), + )); + continue; + } + match resolve_containing_symbol(&raw_file_path, line, ctx) { + Some((symbol_name, sym_start)) => (symbol_name, sym_start), + None => { + // No symbol contains this line (comment, import, blank). + no_symbol_stubs.push(format!( + "{}:{} — {} (no containing symbol)", + raw_file, + line.max(1), + format!("search score={:.3}", score), + )); + continue; + } + } + }; + + if raw_file.is_empty() || name.is_empty() { + continue; + } + + seeds.push(PackCandidate { + file: normalize_file_path(raw_file, &project_root), + name, + start_line: if start_line == 0 { + resolved_start_line + } else { + start_line + }, + provenance: format!("search score={:.3}", score), + score, + seed_ordinal: None, // filled in after sorting + hop_distance: 0, + }); + } + + seeds.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for (i, seed) in seeds.iter_mut().enumerate() { + seed.seed_ordinal = Some(i); + } + + if seeds.is_empty() { + if no_symbol_stubs.is_empty() { + return Response::success( + &req.id, + serde_json::json!({ + "text": "aft_gather_context: no results found for question", + }), + ); + } + // All hits were no-containing-symbol — produce a pack of visible stubs. + let degraded = semantic_status != "ready"; + return build_pack( + req, + ctx, + &[], + &[], + budget, + "question", + question, + &no_symbol_stubs, + degraded, + false, + &project_root, + ); + } + + // Detect callgraph store unavailability so the header carries a notice + // (question mode is best-effort — seeds alone are still useful). + let callgraph_unavailable = !matches!( + ctx.callgraph_store_for_ops(), + CallgraphStoreAccess::Ready(_) + ); + let neighbors = collect_callgraph_neighbors(ctx, &seeds, &project_root, include_tests); + + // Only flag degradation when zero symbol-level seeds resolved. + // Even one real seed means the pack has usable content. + // Intentionally always-false when seeds is non-empty — a degraded + // semantic index that still produces symbol-level results is + // functional enough; flagging it would be noise. + let degraded = semantic_status != "ready" && seeds.is_empty(); + build_pack( + req, + ctx, + &seeds, + &neighbors, + budget, + "question", + question, + &no_symbol_stubs, + degraded, + callgraph_unavailable, + &project_root, + ) +} + +fn handle_gather_symbol( + req: &RawRequest, + ctx: &AppContext, + symbol: &str, + file_path_str: &str, + budget: usize, + include_tests: bool, +) -> Response { + let file_path = match ctx.validate_path(&req.id, Path::new(file_path_str)) { + Ok(path) => path, + Err(resp) => return resp, + }; + + let store = match ctx.callgraph_store_for_ops() { + CallgraphStoreAccess::Ready(store) => store, + CallgraphStoreAccess::Building => return building_response(&req.id, "gather"), + CallgraphStoreAccess::Unavailable => { + return unavailable_response(&req.id, "gather", ctx.is_worktree_bridge()) + } + CallgraphStoreAccess::Error(error) => { + return store_error_response(&req.id, "gather", error) + } + }; + + let impact = match impact_result(store.as_ref(), &file_path, symbol, 1, include_tests) { + Ok(result) => result, + Err(error) => { + return store_error_response(&req.id, "gather", error); + } + }; + + let project_root = grep_executor::project_root(ctx); + let file_display = file_path.display().to_string(); + let seeds = vec![PackCandidate { + file: normalize_file_path(&file_display, &project_root), + name: symbol.to_string(), + start_line: 0, // will be found by resolve + provenance: "seed (impact target)".to_string(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let mut neighbors: Vec = Vec::new(); + for caller in &impact.callers { + neighbors.push(PackCandidate { + file: normalize_file_path(&caller.caller_file, &project_root), + name: caller.caller_symbol.clone(), + start_line: caller.line, + provenance: format!("caller-of-{}", symbol), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }); + } + + let callee_neighbors = collect_callees_for_seed( + ctx, + store.as_ref(), + &file_path, + symbol, + 0, + &project_root, + include_tests, + ); + neighbors.extend(callee_neighbors); + + let mode_desc = format!("impact({}:{})", file_display, symbol); + build_pack( + req, + ctx, + &seeds, + &neighbors, + budget, + &mode_desc, + "", + &[], + false, + false, + &project_root, + ) +} + +/// Collect 1-hop callgraph neighbors (callers + callees) for each seed. +/// +/// Both the caller query below and `collect_callees_for_seed` must forward +/// `include_tests`. Passing the literal `false` to either one reads as correct +/// (`false` is the default) while silently making the flag inert for that half +/// of the neighbor set — the exact drift a partial conversion produces. +fn collect_callgraph_neighbors( + ctx: &AppContext, + seeds: &[PackCandidate], + project_root: &Path, + include_tests: bool, +) -> Vec { + let store = match ctx.callgraph_store_for_ops() { + CallgraphStoreAccess::Ready(store) => store, + _ => return Vec::new(), + }; + + let mut neighbors = Vec::new(); + for (seed_idx, seed) in seeds.iter().enumerate() { + let seed_path = Path::new(&seed.file); + let seed_name = &seed.name; + + if let Ok(result) = impact_result(store.as_ref(), seed_path, seed_name, 1, include_tests) { + for caller in &result.callers { + neighbors.push(PackCandidate { + file: normalize_file_path(&caller.caller_file, project_root), + name: caller.caller_symbol.clone(), + start_line: caller.line, + provenance: format!("caller-of-{}", seed_name), + score: 0.0, + seed_ordinal: Some(seed_idx), + hop_distance: 1, + }); + } + } + + neighbors.extend(collect_callees_for_seed( + ctx, + store.as_ref(), + seed_path, + seed_name, + seed_idx, + project_root, + include_tests, + )); + } + + neighbors +} + +/// Collect direct callees for a single seed symbol. +fn collect_callees_for_seed( + _ctx: &AppContext, + store: &impl crate::callgraph_store::CallGraphRead, + file_path: &Path, + symbol: &str, + seed_idx: usize, + project_root: &Path, + include_tests: bool, +) -> Vec { + let mut callees = Vec::new(); + if let Ok(tree) = call_tree_result(store, file_path, symbol, 1, include_tests) { + for child in &tree.children { + callees.push(PackCandidate { + file: normalize_file_path(&child.file, project_root), + name: child.name.clone(), + start_line: child.line, + provenance: format!("{}{}", CALLEE_PROVENANCE_PREFIX, symbol), + score: 0.0, + seed_ordinal: Some(seed_idx), + hop_distance: 1, + }); + } + } + callees +} + +/// Assemble the final pack text. +/// `pre_stubs` are no-containing-symbol hits from grep-fallback results +/// that must appear as visible stubs (nothing-silently-dropped contract). +fn build_pack( + req: &RawRequest, + ctx: &AppContext, + seeds: &[PackCandidate], + neighbors: &[PackCandidate], + budget: usize, + mode: &str, + question: &str, + pre_stubs: &[String], + degraded: bool, + callgraph_unavailable: bool, + project_root: &Path, +) -> Response { + // Deduplicate by (file, name). Seeds win over neighbors — they carry + // relevance (search score / impact target). Seeds are emitted first, + // then neighbors interleaved per seed. This ordering is intentional: + // under a budget cut, every seed must be included before any neighbor + // consumes lines, because seeds are the primary evidence the agent + // asked for. + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + + for seed in seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + + for seed in seeds { + let seed_idx = seed.seed_ordinal; + for neighbor in neighbors { + if neighbor.seed_ordinal == seed_idx { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + } + } + + for neighbor in neighbors { + if neighbor.seed_ordinal.is_none() { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + } + + let mut lines_used: usize = 0; + let mut body = String::new(); + + let mut stubs: Vec = Vec::new(); + let mut unresolved_count: usize = 0; + + // Per-symbol budget: remaining budget / remaining candidates (at least 10). + let total_candidates = ordered.len(); + + for (i, candidate) in ordered.iter().enumerate() { + let remaining = total_candidates - i; + // .max(10) guarantees per_symbol_budget ≥ 10, so the only + // budget-exhausted shortfall is caught by `lines_used >= budget`. + let per_symbol_budget = (budget.saturating_sub(lines_used)) + .saturating_div(remaining) + .max(10) + .min(150); // cap per symbol at 150 lines + + if lines_used >= budget { + stubs.push(format!( + "{}:{} {} — {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + continue; + } + + match render_symbol_section(ctx, candidate, per_symbol_budget, project_root) { + Ok(section) => { + let section_lines = section.lines().count(); + if lines_used + section_lines + 1 > budget { + // Would exceed budget — stub it instead. + stubs.push(format!( + "{}:{} {} — {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + continue; + } + body.push_str(§ion); + body.push('\n'); + lines_used += section_lines + 1; // section + trailing newline + } + Err(stub) => { + // Suppress unresolved EXTERNAL callees — stdlib/prelude symbols + // (e.g. `readFileSync`, `parse`, `sorted`) are never renderable + // and bury genuine expandable stubs. Unresolved seeds and + // callers remain as visible individual stubs so nothing is + // silently dropped. + if stub.contains(UNRESOLVED_MARKER) + && candidate.provenance.starts_with(CALLEE_PROVENANCE_PREFIX) + { + unresolved_count += 1; + } else { + stubs.push(stub); + } + } + } + } + + // Build header after rendering so used=N is exact and the replacement + // cannot collide with query text (e.g. a query containing "used="). + let degraded_notice = if degraded { + " | degraded=semantic-index-building (partial results — retry when index ready)" + } else { + "" + }; + let callgraph_notice = if callgraph_unavailable { + " | neighbors=skipped(callgraph-unavailable)" + } else { + "" + }; + + let total_lines = lines_used + 1; // +1 for the header line itself + let header = if question.is_empty() { + format!( + "## gather pack | mode={}{}{} | seeds={} | neighbors={} | budget={} used={}", + mode, + degraded_notice, + callgraph_notice, + seeds.len(), + neighbors.len(), + budget, + total_lines, + ) + } else { + format!( + "## gather pack | mode={}{}{} | query=\"{}\" | seeds={} | neighbors={} | budget={} used={}", + mode, + degraded_notice, + callgraph_notice, + truncate_str(question, 80), + seeds.len(), + neighbors.len(), + budget, + total_lines, + ) + }; + + let mut output = header; + output.push('\n'); + output.push_str(&body); + + if !stubs.is_empty() || !pre_stubs.is_empty() { + output.push_str("\n## Beyond budget (zoom to expand)\n"); + for stub in pre_stubs { + output.push_str(stub); + output.push('\n'); + } + for stub in &stubs { + output.push_str(stub); + output.push('\n'); + } + if unresolved_count > 0 { + output.push_str(&format!( + "({} unresolved external calls omitted)\n", + unresolved_count + )); + } + } else if unresolved_count > 0 { + output.push_str(&format!( + "\n## Beyond budget (zoom to expand)\n({} unresolved external calls omitted)\n", + unresolved_count + )); + } + + Response::success( + &req.id, + serde_json::json!({ + "text": output, + }), + ) +} + +/// Select the best match when multiple same-name symbols exist in one file. +/// If the candidate carries a non-zero `start_line` (1-based, from search results), +/// prefer the match whose range contains (or starts nearest to) that line. +/// Falls back to matches[0] when no line hint is available. +fn select_symbol_match<'a>( + matches: &'a [crate::symbols::SymbolMatch], + start_line: u32, +) -> &'a crate::symbols::Symbol { + if start_line == 0 || matches.len() <= 1 { + return &matches[0].symbol; + } + // start_line is 1-based (serialized Range convention); Symbol range fields + // are 0-indexed internally — subtract 1 for comparison. + let hint_line_0b = (start_line.saturating_sub(1)) as u32; + matches + .iter() + .min_by_key(|m| { + let r = &m.symbol.range; + if r.start_line <= hint_line_0b && hint_line_0b <= r.end_line { + 0 // exact containment + } else if r.start_line > hint_line_0b { + (r.start_line - hint_line_0b) as u64 + } else { + (hint_line_0b - r.end_line) as u64 + (u32::MAX as u64) + } + }) + .map(|m| &m.symbol) + .unwrap_or(&matches[0].symbol) +} + +/// Render a single symbol as a markdown section for the pack. +/// Returns Ok(section_text) on success, or Err(stub_line) if the symbol can't be resolved. +fn render_symbol_section( + ctx: &AppContext, + candidate: &PackCandidate, + per_symbol_budget: usize, + project_root: &Path, +) -> Result { + let file_path = if Path::new(&candidate.file).is_absolute() { + PathBuf::from(&candidate.file) + } else { + project_root.join(&candidate.file) + }; + if !file_path.exists() { + return Err(format!( + "{}:{} {} — {} (file not found)", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + } + + let source = match std::fs::read_to_string(&file_path) { + Ok(s) => s, + Err(e) => { + return Err(format!( + "{}:{} {} — {} (read error: {})", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance, + e + )); + } + }; + let lines: Vec = source.lines().map(|l| l.to_string()).collect(); + + let matches = match ctx.provider().resolve_symbol(&file_path, &candidate.name) { + Ok(m) => m, + Err(_) => { + return Err(format!( + "{}:{} {} — {} {}", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance, + UNRESOLVED_MARKER, + )); + } + }; + + if matches.is_empty() { + return Err(format!( + "{}:{} {} — {} (symbol not found)", + candidate.file, + candidate.start_line.max(1), + candidate.name, + candidate.provenance + )); + } + + let target_symbol = select_symbol_match(&matches, candidate.start_line); + + let lang = detect_language(&file_path); + let kind_str = symbol_kind_string(&target_symbol.kind); + + let rendered = + render_symbol_within_budget(target_symbol, &lines, lang, None, per_symbol_budget); + let body = rendered.content.trim().to_string(); + if body.is_empty() { + return Err(format!( + "{}:{} {} — {} (empty body)", + candidate.file, + target_symbol.range.start_line + 1, // 1-based for display + candidate.name, + candidate.provenance + )); + } + + let header = format!( + "## {}:{} {} {}", + candidate.file, + target_symbol.range.start_line + 1, // 1-based for display + kind_str, + candidate.name, + ); + + let truncated_note = match rendered.status { + BudgetedSymbolRenderStatus::Complete => String::new(), + BudgetedSymbolRenderStatus::Truncated => { + format!(" [truncated — zoom {} for full body]", candidate.name) + } + BudgetedSymbolRenderStatus::Menu => { + format!(" [member menu — zoom {} for bodies]", candidate.name) + } + }; + + Ok(format!("{}\n{}\n{}", header, body, truncated_note,)) +} + +/// Truncate `s` to at most `max_len` bytes on a char boundary, appending `…`. +/// Byte-slicing `&s[..max_len]` panics when max_len lands mid-codepoint; +/// this walks `char_indices` to find the last valid boundary ≤ max_len. +fn truncate_str(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + let mut end = 0; + for (i, c) in s.char_indices() { + let char_end = i + c.len_utf8(); + if char_end > max_len { + break; + } + end = char_end; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string() { + assert_eq!(truncate_str("hello", 10), "hello"); + } + + #[test] + fn truncate_long_string() { + assert_eq!(truncate_str("hello world this is long", 10), "hello worl…"); + } + + #[test] + fn truncate_mid_codepoint_regression() { + // "éx": é spans bytes 0-1, x at byte 2. max_len=1 lands inside é. + // Old byte-slice code `&s[..1]` panics with "end byte index 1 is not + // a char boundary; it is inside 'é'". The char_indices walk must not + // panic and must truncate at the last valid boundary ≤ 1 (byte 0 → ""). + let result = truncate_str("éx", 1); + assert_eq!(result, "…"); // empty content + ellipsis + } + + #[test] + fn truncate_mid_codepoint_in_longer_string() { + // 79 'a' + "éxxx" = 83 bytes. max_len=80 lands in second byte of é + // (bytes 79-80 = é). Old byte-slice panics. + let s = format!("{}éxxx", "a".repeat(79)); + let result = truncate_str(&s, 80); + assert!(result.ends_with('…')); + assert!(result.starts_with("aaaa")); + // Should be 79 'a' (all on char boundaries) + ellipsis + assert_eq!(result, format!("{}…", "a".repeat(79))); + } + + #[test] + fn select_symbol_match_by_containment() { + use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch}; + + let first = Symbol { + name: "foo".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 10, + start_col: 0, + end_line: 15, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let second = Symbol { + name: "foo".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 50, + start_col: 0, + end_line: 55, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let matches = vec![ + SymbolMatch { + symbol: first, + file: "a.rs".into(), + }, + SymbolMatch { + symbol: second, + file: "a.rs".into(), + }, + ]; + + // start_line=52 (1-based) → 51 (0-based) falls inside second's range 50-55. + let selected = select_symbol_match(&matches, 52); + assert_eq!(selected.range.start_line, 50); + } + + #[test] + fn select_symbol_match_falls_back_to_first_when_no_line_hint() { + use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch}; + + let first = Symbol { + name: "bar".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 10, + start_col: 0, + end_line: 15, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let second = Symbol { + name: "bar".into(), + kind: SymbolKind::Function, + range: Range { + start_line: 50, + start_col: 0, + end_line: 55, + end_col: 0, + }, + signature: None, + scope_chain: vec![], + exported: false, + parent: None, + }; + let matches = vec![ + SymbolMatch { + symbol: first, + file: "a.rs".into(), + }, + SymbolMatch { + symbol: second, + file: "a.rs".into(), + }, + ]; + + // start_line=0 means "no line hint" → returns matches[0]. + let selected = select_symbol_match(&matches, 0); + assert_eq!(selected.range.start_line, 10); + } + + #[test] + fn dedup_by_file_and_name() { + let seeds = vec![PackCandidate { + file: "a.rs".into(), + name: "foo".into(), + start_line: 1, + provenance: "seed".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + let neighbors = vec![PackCandidate { + file: "a.rs".into(), + name: "foo".into(), + start_line: 1, + provenance: "caller-of-x".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }]; + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + for seed in &seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + for neighbor in &neighbors { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + // Only the seed should be included — neighbor is a duplicate. + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].provenance, "seed"); + } + + #[test] + fn mode_validation_neither_mode() { + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + assert!(req.params.get("question").is_none() && req.params.get("symbol").is_none()); + } + + #[test] + fn mode_validation_both_modes() { + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({ + "question": "how does foo work", + "symbol": "bar", + "filePath": "bar.rs", + }), + }; + let has_question = req + .params + .get("question") + .and_then(|v| v.as_str()) + .is_some(); + let has_symbol = req.params.get("symbol").and_then(|v| v.as_str()).is_some() + && req + .params + .get("filePath") + .and_then(|v| v.as_str()) + .is_some(); + assert!(has_question && has_symbol); + } + + #[test] + fn budget_parsing_defaults_and_caps() { + // Default budget + let req = RawRequest { + id: "1".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + let budget = req + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget, DEFAULT_BUDGET); + + // Explicit budget + let req2 = RawRequest { + id: "2".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({"budget": 200}), + }; + let budget2 = req2 + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget2, 200); + + // Budget capped at MAX_BUDGET + let req3 = RawRequest { + id: "3".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({"budget": 2000}), + }; + let budget3 = req3 + .params + .get("budget") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BUDGET as u64) + .min(MAX_BUDGET as u64) as usize; + assert_eq!(budget3, MAX_BUDGET); + } + + #[test] + fn pack_candidate_ranking_order() { + let mut seeds = vec![ + PackCandidate { + file: "a.rs".into(), + name: "high_score".into(), + start_line: 10, + provenance: "search score=0.900".into(), + score: 0.9, + seed_ordinal: None, + hop_distance: 0, + }, + PackCandidate { + file: "b.rs".into(), + name: "low_score".into(), + start_line: 5, + provenance: "search score=0.300".into(), + score: 0.3, + seed_ordinal: None, + hop_distance: 0, + }, + ]; + seeds.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + assert_eq!(seeds[0].name, "high_score"); + assert_eq!(seeds[1].name, "low_score"); + } + + #[test] + fn render_symbol_section_header_format() { + // Just verify the header format string works. + let header = format!( + "## {}:{} {} {}", + "src/main.rs", 42, "function", "handle_request" + ); + assert_eq!(header, "## src/main.rs:42 function handle_request"); + } + + #[test] + fn stub_format_includes_provenance() { + let stub = format!( + "{}:{} {} — {}", + "src/lib.rs", 15, "helper_fn", "callee-of-main" + ); + assert_eq!(stub, "src/lib.rs:15 helper_fn — callee-of-main"); + } + + // ── regression tests for live-use fixes ── + + #[test] + fn normalize_abs_path_strips_project_root() { + // (c) absolute and relative forms of the same file normalize to one key. + #[cfg(windows)] + let (root, abs) = ( + Path::new(r"C:\Users\x\project"), + r"C:\Users\x\project\skills\council\scripts\score.py", + ); + #[cfg(not(windows))] + let (root, abs) = ( + Path::new("/home/user/project"), + "/home/user/project/skills/council/scripts/score.py", + ); + let rel = "skills/council/scripts/score.py"; + let dot_prefix = "./skills/council/scripts/score.py"; + + let from_abs = normalize_file_path(abs, root); + let from_rel = normalize_file_path(rel, root); + let from_dot = normalize_file_path(dot_prefix, root); + + assert_eq!(from_abs, "skills/council/scripts/score.py"); + assert_eq!(from_rel, "skills/council/scripts/score.py"); + assert_eq!(from_dot, "skills/council/scripts/score.py"); + } + + #[test] + fn normalize_rel_path_preserved_for_non_root_abs() { + let root = Path::new("/home/user/project"); + // An absolute path outside the project root stays absolute. + let outside = "/other/repo/src/main.rs"; + assert_eq!(normalize_file_path(outside, root), outside); + } + + #[test] + fn dedupe_normalized_keys_merge_abs_and_rel() { + // (c) two candidates with the same (file, name) after normalization + // merge into one section. + #[cfg(windows)] + let (root, abs) = ( + Path::new(r"C:\Users\x\project"), + r"C:\Users\x\project\skills\score.py", + ); + #[cfg(not(windows))] + let (root, abs) = ( + Path::new("/home/user/project"), + "/home/user/project/skills/score.py", + ); + let seeds = vec![PackCandidate { + file: normalize_file_path(abs, root), + name: "run_executor_only".into(), + start_line: 243, + provenance: "search score=0.900".into(), + score: 0.9, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + let neighbors = vec![PackCandidate { + file: normalize_file_path("skills/score.py", root), + name: "run_executor_only".into(), + start_line: 243, + provenance: "caller-of-x".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }]; + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut ordered: Vec<&PackCandidate> = Vec::new(); + for seed in &seeds { + let key = (seed.file.clone(), seed.name.clone()); + if seen.insert(key) { + ordered.push(seed); + } + } + for neighbor in &neighbors { + let key = (neighbor.file.clone(), neighbor.name.clone()); + if seen.insert(key) { + ordered.push(neighbor); + } + } + // Only the seed should be included — neighbor is the same symbol. + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].provenance, "search score=0.900"); + } + + #[test] + fn normalize_backslash_absolute_and_forward_slash_relative_to_same_key() { + let root = Path::new("/home/user/project"); + let abs_backslash = r"\home\user\project\skills\council\scripts\score.py"; + let rel_forward_slash = "skills/council/scripts/score.py"; + + assert_eq!( + normalize_file_path(abs_backslash, root), + normalize_file_path(rel_forward_slash, root) + ); + assert_eq!( + normalize_file_path(abs_backslash, root), + "skills/council/scripts/score.py" + ); + } + + #[test] + fn resolve_containing_symbol_finds_inner_function() { + // (a) resolve_containing_symbol must find the symbol whose range + // contains the hit line — not just the first match. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + // Write a temp file with known structure. + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.py"); + std::fs::write( + &file_path, + "#!/usr/bin/env python3\n\ndef outer():\n pass\n\ndef target_func(arg):\n return arg\n\ndef bottom():\n pass\n", + ) + .unwrap(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // Line 7 (1-based) = ` return arg` — contained by target_func + // which starts at line 6 (1-based). + let result = resolve_containing_symbol(&file_path, 7, &ctx); + assert!( + result.is_some(), + "must resolve containing symbol for line 7" + ); + let (name, start) = result.unwrap(); + assert_eq!(name, "target_func"); + assert_eq!(start, 6); + + // Line 2 (blank/comment) — no containing symbol. + let no_sym = resolve_containing_symbol(&file_path, 2, &ctx); + assert!( + no_sym.is_none(), + "blank line should have no containing symbol" + ); + } + + #[test] + fn suppression_scopes_callee_only_in_real_pack() { + // SHOULD: drive the PRODUCTION build_pack path — candidates that hit + // render errors must be scoped: callee-only suppression, seed+caller + // stay visible in the Beyond-budget stub list. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // Seeds: one real (renders), one bogus (unresolved seed → visible stub). + let seeds = vec![ + PackCandidate { + file: file_display.clone(), + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }, + PackCandidate { + file: file_display.clone(), + name: "ghost_seed".into(), + start_line: 0, + provenance: "search score=0.500".into(), + score: 0.5, + seed_ordinal: Some(1), + hop_distance: 0, + }, + ]; + + // Neighbors: bogus names in the same file — all produce + // "(symbol not resolved)". + let neighbors = vec![ + PackCandidate { + file: file_display.clone(), + name: "bogus_callee".into(), + start_line: 0, + provenance: "callee-of-foo".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }, + PackCandidate { + file: file_display.clone(), + name: "bogus_caller".into(), + start_line: 0, + provenance: "caller-of-foo".into(), + score: 0.0, + seed_ordinal: Some(0), + hop_distance: 1, + }, + ]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + let budget = 500; + let resp = build_pack( + &req, + &ctx, + &seeds, + &neighbors, + budget, + "question", + "test query", + &[], + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + + // Callee-neighbor must NOT appear as a visible stub — suppressed. + assert!( + !text.contains("bogus_callee — callee-of-foo (symbol not resolved)"), + "unresolved callee must be suppressed, not visible stub" + ); + + // Caller-neighbor MUST appear as a visible stub. + assert!( + text.contains("bogus_caller — caller-of-foo (symbol not resolved)"), + "unresolved caller must remain visible stub" + ); + + // Unresolved seed MUST appear as a visible stub. + assert!( + text.contains("ghost_seed — search score=0.500 (symbol not resolved)"), + "unresolved seed must remain visible stub" + ); + + // Unresolved count line must mention callee omission. + assert!( + text.contains("(1 unresolved external calls omitted)"), + "must report exactly one suppressed callee" + ); + } + + #[test] + fn no_containing_symbol_hits_produce_visible_stubs() { + // MUST: grep-fallback hits with no containing symbol (comment, + // import, blank line) must produce visible stubs — not silent drops. + // The degenerate "all stubs" case must produce a pack, not + // "no results found". + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("comments.py"); + // A file where line 2 is a comment (no containing symbol) + // and line 4 is a blank line. + std::fs::write(&file_path, "# top-level comment\n\n# another comment\n\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // Simulate three grep-fallback hits: two comment lines, one nonexistent file. + let pre_stubs: Vec = vec![ + format!( + "{}:2 — search score=0.800 (no containing symbol)", + file_display + ), + format!( + "{}:4 — search score=0.600 (no containing symbol)", + file_display + ), + "nonexistent.rs:10 — search score=0.400 (no containing symbol)".into(), + ]; + + let resp = build_pack( + &req, + &ctx, + &[], + &[], + 400, + "question", + "test query", + &pre_stubs, + true, + false, + dir.path(), + ); + + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + + // Must have a pack header (even though no seeds). + assert!( + text.contains("## gather pack"), + "empty-seed pack must still have header" + ); + + // All three stubs must be visible in the Beyond budget section. + assert!( + text.contains("(no containing symbol)"), + "no-containing-symbol stubs must be visible" + ); + assert!( + text.contains("nonexistent.rs:10"), + "nonexistent file stub must be visible" + ); + + // Must NOT say "no results found" — that was the old silent-drop. + assert!( + !text.contains("no results found"), + "must not silently drop no-containing-symbol hits" + ); + } + + #[test] + fn fully_degraded_pack_flags_semantic_index_building() { + // (a) When semantic_status != "ready" and all hits are no-symbol + // stubs, the header carries a degradation notice. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("blank.py"); + std::fs::write(&file_path, "# comment\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + let pre_stubs = vec![format!( + "{}:1 — search score=0.250 (no containing symbol)", + file_display + )]; + + // degraded=true → header should carry the flag. + let resp = build_pack( + &req, + &ctx, + &[], + &[], + 400, + "question", + "test query", + &pre_stubs, + true, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + text.contains("degraded=semantic-index-building"), + "degraded pack must carry semantic-index-building flag in header" + ); + } + + #[test] + fn normal_pack_has_no_degradation_flag() { + // (b) Normal results (seeds resolved) must NOT carry the degradation flag. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("normal.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let seeds = vec![PackCandidate { + file: file_display, + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // degraded=false — normal case. + let resp = build_pack( + &req, + &ctx, + &seeds, + &[], + 400, + "question", + "test query", + &[], + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + !text.contains("degraded=semantic-index-building"), + "normal pack must not carry degradation flag" + ); + assert!( + text.contains("def foo"), + "normal pack must contain seed body" + ); + } + + #[test] + fn mixed_results_with_one_real_seed_no_degradation_flag() { + // (c) Even when degraded=true, if at least one symbol-level seed + // resolved, the pack has usable content — no flag. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("mixed.py"); + std::fs::write(&file_path, "def foo():\n pass\n").unwrap(); + let file_display = file_path.display().to_string(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + // One real seed (resolves) + some no-symbol stubs. + let seeds = vec![PackCandidate { + file: file_display.clone(), + name: "foo".into(), + start_line: 1, + provenance: "search score=1.000".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }]; + + let pre_stubs = vec![format!( + "{}:1 — search score=0.250 (no containing symbol)", + file_display + )]; + + let req = RawRequest { + id: "test".into(), + command: "gather".into(), + lsp_hints: None, + session_id: None, + params: serde_json::json!({}), + }; + + // Production path: handle_gather_question sets degraded=false when + // seeds is non-empty (even one resolved seed → not degraded). + let resp = build_pack( + &req, + &ctx, + &seeds, + &[], + 400, + "question", + "test query", + &pre_stubs, + false, + false, + dir.path(), + ); + let text = resp.data.get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + !text.contains("degraded=semantic-index-building"), + "mixed pack with one real seed must not carry degradation flag" + ); + assert!( + text.contains("def foo"), + "mixed pack must contain seed body" + ); + } + + #[test] + fn render_resolves_repo_relative_path_against_project_root() { + // (f) render_symbol_section must resolve repo-relative candidate.file + // against project_root, not process CWD. A unique temp dir ensures + // the relative path cannot exist relative to CWD — only project_root + // join produces a valid path. + use crate::config::Config; + use crate::parser::TreeSitterProvider; + + let project_root = tempfile::tempdir().unwrap(); + let file_name = "test_cwd.py"; + let file_path = project_root.path().join(file_name); + std::fs::write(&file_path, "def answer():\n return 42\n").unwrap(); + + let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); + + let candidate = PackCandidate { + file: file_name.to_string(), + name: "answer".into(), + start_line: 1, + provenance: "test".into(), + score: 1.0, + seed_ordinal: Some(0), + hop_distance: 0, + }; + + let result = render_symbol_section(&ctx, &candidate, 50, project_root.path()); + assert!( + result.is_ok(), + "repo-relative path must resolve via project_root; got: {:?}", + result + ); + let section = result.unwrap(); + assert!( + section.contains("def answer"), + "rendered section must contain symbol body; got: {}", + section + ); + } +} diff --git a/crates/aft/src/commands/mod.rs b/crates/aft/src/commands/mod.rs index ba9bc165..4affa65d 100644 --- a/crates/aft/src/commands/mod.rs +++ b/crates/aft/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod edit_history; pub mod edit_match; pub mod edit_symbol; pub mod extract_function; +pub mod gather; pub mod glob; pub mod grep; pub mod hashline; diff --git a/crates/aft/src/main.rs b/crates/aft/src/main.rs index 312796f4..cc422738 100644 --- a/crates/aft/src/main.rs +++ b/crates/aft/src/main.rs @@ -779,6 +779,7 @@ fn dispatch(req: RawRequest, ctx: &AppContext) -> Response { "trace_to" => aft::commands::trace_to::handle_trace_to(&req, ctx), "trace_to_symbol" => aft::commands::trace_to_symbol::handle_trace_to_symbol(&req, ctx), "impact" => aft::commands::impact::handle_impact(&req, ctx), + "gather" => aft::commands::gather::handle_gather(&req, ctx), "trace_data" => aft::commands::trace_data::handle_trace_data(&req, ctx), "move_symbol" => aft::commands::move_symbol::handle_move_symbol(&req, ctx), "extract_function" => aft::commands::extract_function::handle_extract_function(&req, ctx), diff --git a/crates/aft/src/subc/manifest.rs b/crates/aft/src/subc/manifest.rs index 910576de..408a5c2f 100644 --- a/crates/aft/src/subc/manifest.rs +++ b/crates/aft/src/subc/manifest.rs @@ -26,6 +26,7 @@ pub(super) fn is_subc_agent_core_tool(name: &str) -> bool { | "zoom" | "inspect" | "callgraph" + | "gather" | "conflicts" | "ast_search" | "ast_replace" @@ -129,8 +130,8 @@ pub(super) fn command_lane_explicit(command: &str) -> Option { | "lsp_find_references" | "lsp_prepare_rename" => Some(Lane::SerialLspStatus), - "semantic_search" | "search" | "callgraph" | "callers" | "impact" | "call_tree" - | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => { + "semantic_search" | "search" | "callgraph" | "gather" | "callers" | "impact" + | "call_tree" | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => { Some(Lane::HeavyInit) } @@ -237,6 +238,7 @@ pub(super) fn build_manifest() -> ModuleManifest { tool("grep", ExecutionMode::Pure), tool("glob", ExecutionMode::Pure), tool("search", ExecutionMode::Pure), + tool("gather", ExecutionMode::Pure), tool("outline", ExecutionMode::Pure), tool("zoom", ExecutionMode::Pure), tool("inspect", ExecutionMode::Pure), @@ -300,6 +302,7 @@ mod tests { "grep", "glob", "search", + "gather", "outline", "zoom", "inspect", diff --git a/crates/aft/src/subc_tool_schemas.json b/crates/aft/src/subc_tool_schemas.json index cac59c14..240ab72e 100644 --- a/crates/aft/src/subc_tool_schemas.json +++ b/crates/aft/src/subc_tool_schemas.json @@ -278,6 +278,35 @@ ], "description": "Search code with one tool: concepts, identifiers, error strings, regex, literals, and filenames are auto-routed to the right engine and returned ranked. For conceptual 'how does X work' queries, phrase a full natural-language sentence — the semantic lane is NL-aware and matches intent against docstrings and comments ('how does the ORM build and execute a query', 'where is rate limiting handled'), not just keywords. Exact names, strings, and regex stay terse ('^export', 'Cargo.lock')." }, + "gather": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "question": { + "description": "Natural-language question to seed the pack via semantic search. Mutually exclusive with 'symbol'+'path'.", + "type": "string" + }, + "symbol": { + "description": "Symbol name for impact-seeded mode. Requires 'path'. Mutually exclusive with 'question'.", + "type": "string" + }, + "path": { + "description": "Path to the source file for impact-seeded mode. Required when 'symbol' is provided. Mutually exclusive with 'question'.", + "type": "string" + }, + "budget": { + "description": "Output line budget for the pack (default 400, max 800). Budget-excluded candidates are listed as stubs.", + "type": "integer", + "minimum": 1, + "maximum": 800 + }, + "includeTests": { + "description": "Include test files in callers/paths. Defaults to false; tests are hidden.", + "type": "boolean" + } + }, + "description": "Assemble a deterministic 'context pack' — ranked, deduped, budgeted verbatim code evidence — in ONE call instead of a multi-turn search→outline→zoom→callgraph chain. Returns code bodies with file:line headers, not conclusions.\n\nTwo modes (mutually exclusive):\n- question mode: `{ question: \"how does X work?\" }` — semantic-search-seeded. Ranks seeds by search score.\n- symbol mode: `{ symbol: \"handle_zoom\", path: \"src/commands/zoom.rs\" }` — impact-seeded (blast-radius callers + callees).\n\nOptional: `budget` (default 400 lines, max 800). When the budget is exhausted, remaining candidates appear as one-line stubs under '## Beyond budget (zoom to expand)'.\n\nUse when: the agent would otherwise need 4-6 serial tools to gather code context around a question or symbol. NOT for quick single-symbol reads (use aft_zoom)." + }, "outline": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/crates/aft/src/subc_translate.rs b/crates/aft/src/subc_translate.rs index 0c783d05..a73f3518 100644 --- a/crates/aft/src/subc_translate.rs +++ b/crates/aft/src/subc_translate.rs @@ -853,6 +853,7 @@ pub(crate) fn supports_tool(bare_name: &str) -> bool { | "import" | "refactor" | "safety" + | "gather" ) } @@ -945,6 +946,7 @@ pub fn subc_translate_owned_with_context( "zoom" => translate_zoom(agent_args, project_root), "inspect" => translate_inspect(agent_args, project_root), "callgraph" => translate_callgraph(agent_args, project_root), + "gather" => translate_gather(agent_args, project_root), "conflicts" => translate_conflicts(agent_args), "ast_search" => translate_ast_search(agent_args), "ast_replace" => translate_ast_replace(agent_args), @@ -1846,6 +1848,56 @@ fn translate_safety(args: Value, project_root: &Path) -> Result Result { + let map_in = agent_args_map(args); + let has_question = map_in + .get("question") + .and_then(Value::as_str) + .map(|s| !s.is_empty()) + .unwrap_or(false); + let has_symbol = map_in + .get("symbol") + .and_then(Value::as_str) + .map(|s| !s.is_empty()) + .unwrap_or(false); + let has_file_path = gather_file_path(&map_in).is_some(); + + if has_question && (has_symbol || has_file_path) { + return Err(invalid_request( + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'filePath'", + )); + } + if has_symbol != has_file_path { + return Err(invalid_request( + "aft_gather_context: 'symbol' and 'filePath' must be provided together", + )); + } + if !has_question && !has_symbol && !has_file_path { + return Err(invalid_request( + "aft_gather_context: provide either 'question' or 'symbol'+'filePath'", + )); + } + + let mut out = Map::new(); + for key in &["question", "symbol", "budget", "includeTests"] { + if let Some(value) = map_in.get(*key) { + out.insert(key.to_string(), value.clone()); + } + } + if let Some(file_path) = gather_file_path(&map_in) { + let resolved = resolve_path_from_project_root(project_root, file_path); + out.insert( + "filePath".to_string(), + Value::String(resolved.to_string_lossy().into_owned()), + ); + } + + Ok(Translated { + command: "gather".into(), + args: out, + }) +} + fn insert_non_empty_array(out: &mut Map, map_in: &Map, key: &str) { if let Some(value) = map_in.get(key) { if let Some(items) = value.as_array() { @@ -2191,6 +2243,22 @@ fn translate_zoom_targets( Ok(out) } +/// Read gather's file argument under either spelling. +/// +/// The tool schema advertises `path`, matching `zoom` and `grep`, so an MCP +/// client reading the manifest sends that. The OpenCode adapter renames its own +/// `path` parameter to `filePath` before calling the bridge, so first-party +/// traffic arrives under the internal spelling. Accepting only one silently +/// breaks the other: reading `filePath` alone made every schema-conformant +/// symbol-mode call fail the together-check, since neither the presence test +/// nor the copy-to-output step could see the argument. +fn gather_file_path(map_in: &Map) -> Option<&str> { + ["path", "filePath"] + .into_iter() + .find_map(|key| map_in.get(key).and_then(Value::as_str)) + .filter(|value| !value.is_empty()) +} + fn translate_zoom(args: Value, project_root: &Path) -> Result { let map_in = agent_args_map(args); @@ -2824,6 +2892,128 @@ mod tests { assert!(translated.args.get("hint").is_none()); } + /// The schema advertises `path`, so this is what a manifest-reading client + /// actually sends. Testing only `filePath` masked a bug where every + /// schema-conformant symbol-mode call failed the together-check. + #[test] + fn gather_accepts_the_schema_advertised_path_key() { + let project_root = Path::new("/project"); + let translated = subc_translate_owned( + "gather", + serde_json::json!({ + "symbol": "target", + "path": "src/foo.rs" + }), + project_root, + ) + .expect("schema-advertised 'path' must be accepted"); + + assert_eq!(translated.command, "gather"); + let expected = resolve_path_from_project_root(project_root, "src/foo.rs"); + assert_eq!( + translated.args.get("filePath").and_then(Value::as_str), + Some(expected.to_string_lossy().as_ref()), + "'path' must resolve and be written out under the internal 'filePath' key" + ); + } + + /// The OpenCode adapter renames `path` to `filePath` before calling the + /// bridge, so first-party traffic arrives under the internal spelling. + #[test] + fn gather_resolves_relative_file_path_from_project_root() { + let project_root = Path::new("/project"); + let translated = subc_translate_owned( + "gather", + serde_json::json!({ + "symbol": "target", + "filePath": "src/foo.rs" + }), + project_root, + ) + .expect("valid gather symbol mode"); + + assert_eq!(translated.command, "gather"); + // Build the expectation with the platform's own separator: on Windows + // `join` yields `\project\src\foo.rs`, so a hardcoded forward-slash + // literal fails there while the resolution is correct. + let expected = resolve_path_from_project_root(project_root, "src/foo.rs"); + assert_eq!( + translated.args.get("filePath").and_then(Value::as_str), + Some(expected.to_string_lossy().as_ref()) + ); + } + + #[test] + fn gather_preserves_include_tests() { + let translated = subc_translate_owned( + "gather", + serde_json::json!({ + "symbol": "target", + "path": "src/foo.rs", + "includeTests": true + }), + Path::new("/project"), + ) + .expect("valid gather symbol mode"); + + assert_eq!( + translated.args.get("includeTests").and_then(Value::as_bool), + Some(true) + ); + } + + /// Every key the schema advertises must be readable by the translator. + /// Derived from the embedded schema rather than hardcoded, so adding a + /// parameter without wiring it fails here instead of in production. + #[test] + fn gather_translator_reads_every_schema_advertised_key() { + let schemas: Map = + serde_json::from_str(include_str!("subc_tool_schemas.json")) + .expect("embedded subc tool schemas must parse"); + let advertised: Vec = schemas + .get("gather") + .and_then(|schema| schema.get("properties")) + .and_then(Value::as_object) + .expect("gather schema must advertise properties") + .keys() + .cloned() + .collect(); + + assert!( + advertised.contains(&"path".to_string()), + "schema is expected to advertise 'path'; update this test if it is renamed" + ); + + for key in &advertised { + let mut args = Map::new(); + match key.as_str() { + "question" => { + args.insert("question".into(), Value::String("how does x work".into())); + } + "symbol" | "path" => { + args.insert("symbol".into(), Value::String("target".into())); + args.insert("path".into(), Value::String("src/foo.rs".into())); + } + "budget" => { + args.insert("question".into(), Value::String("q".into())); + args.insert("budget".into(), Value::from(200)); + } + "includeTests" => { + args.insert("question".into(), Value::String("q".into())); + args.insert("includeTests".into(), Value::Bool(true)); + } + other => panic!("unhandled advertised gather key '{other}' — wire it here"), + } + + let translated = + subc_translate_owned("gather", Value::Object(args), Path::new("/project")) + .unwrap_or_else(|error| { + panic!("advertised key '{key}' was rejected by the translator: {error:?}") + }); + assert_eq!(translated.command, "gather"); + } + } + // supports_tool() gates whether run_tool_call translates or passes a name // through as a native command. If a translate arm is added but the // allowlist isn't updated, that tool would silently bypass translation and @@ -2852,6 +3042,7 @@ mod tests { "import", "refactor", "safety", + "gather", ] { // Every name the allowlist claims support for must actually // translate (not return unsupported_tool). A no-arg call may fail diff --git a/crates/aft/tests/integration/gather_test.rs b/crates/aft/tests/integration/gather_test.rs new file mode 100644 index 00000000..c3b98c95 --- /dev/null +++ b/crates/aft/tests/integration/gather_test.rs @@ -0,0 +1,115 @@ +//! Integration tests for the `gather` command. + +use crate::helpers::AftProcess; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn configure_project(aft: &mut AftProcess, root: &Path) { + let resp = aft.send(&format!( + r#"{{"id":"configure","command":"configure","harness":"opencode","project_root":{}}}"#, + crate::helpers::json_string(&root.display()) + )); + assert_eq!(resp["success"], true, "configure should succeed: {resp:?}"); +} + +#[test] +fn gather_symbol_mode_hides_and_includes_test_callers() { + let temp = tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("src/__tests__")).unwrap(); + fs::write( + root.join("src/target.ts"), + r#"export function target(): number { + return 1; +} + +export function smallTarget(): number { + return 2; +} +"#, + ) + .unwrap(); + + for idx in 0..20 { + fs::write( + root.join(format!("src/caller{idx:02}.ts")), + format!( + r#"import {{ target }} from "./target"; + +export function caller{idx:02}(): number {{ + return target(); +}} +"# + ), + ) + .unwrap(); + } + for idx in 0..5 { + fs::write( + root.join(format!("src/__tests__/caller{idx:02}.test.ts")), + format!( + r#"import {{ target, smallTarget }} from "../target"; + +export function aaaTestCaller{idx:02}(): number {{ + return target() + smallTarget(); +}} +"# + ), + ) + .unwrap(); + } + for idx in 0..3 { + fs::write( + root.join(format!("src/small_caller{idx:02}.ts")), + format!( + r#"import {{ smallTarget }} from "./target"; + +export function smallCaller{idx:02}(): number {{ + return smallTarget(); +}} +"# + ), + ) + .unwrap(); + } + + let mut aft = AftProcess::spawn(); + configure_project(&mut aft, root); + + let target_path = root.join("src/target.ts"); + let target_path_json = crate::helpers::json_string(&target_path.display()); + let default_response = aft.send(&format!( + r#"{{"id":"gather-default","command":"gather","symbol":"target","filePath":{} }}"#, + target_path_json + )); + assert_eq!( + default_response["success"], true, + "default gather should succeed: {default_response:?}" + ); + let default_text = default_response["text"] + .as_str() + .unwrap_or_else(|| panic!("default gather response missing text: {default_response:?}")); + assert!( + !default_text.contains("__tests__"), + "default gather should hide test callers; response: {default_response:?}" + ); + + let include_response = aft.send(&format!( + r#"{{"id":"gather-with-tests","command":"gather","symbol":"target","filePath":{},"includeTests":true }}"#, + crate::helpers::json_string(&target_path.display()) + )); + assert_eq!( + include_response["success"], true, + "includeTests gather should succeed: {include_response:?}" + ); + let include_text = include_response["text"].as_str().unwrap_or_else(|| { + panic!("includeTests gather response missing text: {include_response:?}") + }); + assert!( + include_text.contains("__tests__"), + "includeTests gather should show test callers; response: {include_response:?}" + ); + + aft.shutdown(); +} diff --git a/crates/aft/tests/integration/main.rs b/crates/aft/tests/integration/main.rs index b2739db4..e38bc0de 100644 --- a/crates/aft/tests/integration/main.rs +++ b/crates/aft/tests/integration/main.rs @@ -43,6 +43,7 @@ mod extract_function_test; mod extract_tokens_test; mod format_test; mod fs_lock_audit_test; +mod gather_test; mod grep_glob_multi_path_test; mod grep_glob_test; mod honest_failures_test; diff --git a/docs/v0.49-agent-surface-manifest.json b/docs/v0.49-agent-surface-manifest.json index 59490c30..796c6ea5 100644 --- a/docs/v0.49-agent-surface-manifest.json +++ b/docs/v0.49-agent-surface-manifest.json @@ -3,7 +3,7 @@ "artifact_id": "ART-V049-S5-AGENT-SURFACE-MANIFEST-001", "artifact_version": "0.49.0", "manifest_id": "MAN-V049-S5-AGENT-SURFACE-001", - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "source_inventory": "docs/v0.49-agent-surface-sources.json", "hash_rule": "Hash exact UTF-8 file bytes from the source commit; do not normalize newlines, reserialize JSON, or apply test-only normalization.", "artifacts": [ @@ -17,7 +17,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 48140, "sha256": "6dae60787538cb68d32c78b9ec0b736168653d6e8d96453fdebe61b08bfa71e0" @@ -32,7 +32,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 17922, "sha256": "2d41d04ad7e0cb97d0b1064ab584e7daa8f1db5f7a8cb4d73732c0db1696e9df" @@ -45,7 +45,7 @@ "profiles": [ "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 7944, "sha256": "673c050c76f1ae592440c4574d07708b26a98ba0af5f2bace81c690be289f676" @@ -60,7 +60,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 8228, "sha256": "83185ecfad76ac049bddb97a82b9c0265976067107f2cd5e13d69b6acc7352ca" @@ -74,7 +74,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 6092, "sha256": "4dc0ba1675a425dcfe9fa08fb49bda9d3fedaa7044a25f9b6ac333ee79c3b46e" @@ -87,7 +87,7 @@ "profiles": [ "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 7766, "sha256": "1db4595041262d7e0a977154c2e1eca67b2d7fd1509428322aba6b7b58164f0e" @@ -102,7 +102,7 @@ "REG-V049-OC-REC", "REG-V049-OC-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 12504, "sha256": "213ef9b09bcc8854e00a4a3240791223ed0d338a33ee9667368075ab1b486954" @@ -117,7 +117,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 43259, "sha256": "fe3c438db7717b3678069155d4455eb21e3594affdd04e44005e3a06892ea0f3" @@ -132,7 +132,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 21652, "sha256": "719b244afe0ac41e167db6ce1714f89fea6daa5d6cab79113b184b41bb1edaeb" @@ -145,7 +145,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 8789, "sha256": "7f8764e69b85378cb820c3963116ab65296fc45310d2555d096ff5fdee87cee2" @@ -160,7 +160,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 9499, "sha256": "8261d91d898f1423ddb591d8c30d0285492c19f841d0f3721c05d2f0842ff5c2" @@ -174,7 +174,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 8182, "sha256": "b26a8f7761f99dfdd46b9f1b96115377d6102f0cbd4ecb3b4c1f2599c7a743fb" @@ -187,7 +187,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 8760, "sha256": "47f557a3b8ead3a9c863e99218b6016959de85a4c8be48a2e2e5b65e4742c836" @@ -200,7 +200,7 @@ "profiles": [ "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 9967, "sha256": "416d5dcf384248e0727d9a04e23556d14b98a51dfd57a9ae2c350ebaf285a51a" @@ -215,7 +215,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 11887, "sha256": "5c67943dc898c2a4235c033c5947faa30d64c05de7dc75dee3f2e842dcf89b4a" @@ -233,7 +233,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 44313, "sha256": "b604c6f80c2527582b457a4d8538bd9c4cc8987a6fac449a60e21e8780faa131" @@ -248,7 +248,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 7483, "sha256": "9af0a724fe023baa16657f1c698936e17b73fd943453882117ebda79a19b2ac2" @@ -259,10 +259,10 @@ "kind": "generated subc manifest schema", "owner": "subc-schema-generation", "profiles": [], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", - "byte_length": 42099, - "sha256": "561b2677b11de8c7005aa299b5b57b96e36dc0d74a6d46b2e8eff326fb7d8d4d" + "byte_length": 44034, + "sha256": "799b2718a2819b621c3b71e3973543e87277b4699c221b8519a4563de088fc3d" }, { "id": "ART-V049-HASHLINE-EDIT-SCHEMAS-001-BYTES", @@ -277,7 +277,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 4619, "sha256": "da7caf8b405d2394a92360827e0627c9e364d715078ef614b7f2b2f7f04c2e37" @@ -295,7 +295,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 5519, "sha256": "98fe609fa669db5a64f751f44a097131298c6783868e31fc22d4e1e1c98aac62" @@ -313,10 +313,10 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", - "byte_length": 222936, - "sha256": "e271147d8c108f25b0e8ffd38043f606dd484fc3f6608ba282fba0cfa41b5784" + "byte_length": 229833, + "sha256": "d6c4829361a7b797246ae37c3398cc58fc43aa610a0cc6878db0ae7b13a019dd" }, { "id": "ART-V049-S5-AUDIT-IMPLEMENTATION-001", @@ -331,7 +331,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 23451, "sha256": "c72cc1c1bc82575057552d62900174e57a31e50abf6e6d38b1c25679bcaaa872" @@ -349,7 +349,7 @@ "REG-V049-PI-REC", "REG-V049-PI-ALL" ], - "source_commit": "fc6f3cb7c920c5d6c83f33e41d60e1ef018cedde", + "source_commit": "7517af6e6d6bed04b9a69040cec9236492d0312c", "encoding": "UTF-8", "byte_length": 244929, "sha256": "796bc2bb6739a86980d9fdd52f394f10574e55dc545cc7de4aac3712397a8ed0" diff --git a/docs/v0.49-legacy-vocabulary-allowlist.json b/docs/v0.49-legacy-vocabulary-allowlist.json index 279b538d..839735cf 100644 --- a/docs/v0.49-legacy-vocabulary-allowlist.json +++ b/docs/v0.49-legacy-vocabulary-allowlist.json @@ -24,6 +24,15 @@ "class": "internal-compatibility", "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." }, + { + "path": "ARCHITECTURE.md", + "location": "line 115, column 400", + "line": 115, + "column": 400, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, { "path": "STRUCTURE.md", "location": "line 127, column 53", @@ -96,6 +105,42 @@ "class": "rust-compatibility", "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 93, column 41", + "line": 93, + "column": 41, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 102, column 92", + "line": 102, + "column": 92, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 1014, column 18", + "line": 1014, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/commands/gather.rs", + "location": "line 1025, column 23", + "line": 1025, + "column": 23, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, { "path": "crates/aft/src/commands/trace_to_symbol.rs", "location": "line 60, column 39", @@ -350,8 +395,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 360, column 53", - "line": 360, + "location": "line 363, column 53", + "line": 363, "column": 53, "token": "filePath", "class": "rust-compatibility", @@ -359,8 +404,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 365, column 38", - "line": 365, + "location": "line 368, column 38", + "line": 368, "column": 38, "token": "filePath", "class": "rust-compatibility", @@ -368,8 +413,8 @@ }, { "path": "crates/aft/src/subc/manifest.rs", - "location": "line 366, column 57", - "line": 366, + "location": "line 369, column 57", + "line": 369, "column": 57, "token": "filePath", "class": "rust-compatibility", @@ -611,8 +656,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1168, column 18", - "line": 1168, + "location": "line 1170, column 18", + "line": 1170, "column": 18, "token": "toFile", "class": "rust-compatibility", @@ -620,8 +665,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1658, column 77", - "line": 1658, + "location": "line 1660, column 77", + "line": 1660, "column": 77, "token": "filePath", "class": "rust-compatibility", @@ -629,8 +674,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 1706, column 79", - "line": 1706, + "location": "line 1708, column 79", + "line": 1708, "column": 79, "token": "filePath", "class": "rust-compatibility", @@ -638,8 +683,44 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2166, column 39", - "line": 2166, + "location": "line 1867, column 92", + "line": 1867, + "column": 92, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1872, column 48", + "line": 1872, + "column": 48, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1877, column 73", + "line": 1877, + "column": 73, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 1890, column 14", + "line": 1890, + "column": 14, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2218, column 39", + "line": 2218, "column": 39, "token": "filePath", "class": "rust-compatibility", @@ -647,8 +728,35 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2213, column 56", - "line": 2213, + "location": "line 2250, column 26", + "line": 2250, + "column": 26, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2252, column 32", + "line": 2252, + "column": 32, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2256, column 15", + "line": 2256, + "column": 15, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2281, column 56", + "line": 2281, "column": 56, "token": "filePath", "class": "rust-compatibility", @@ -656,8 +764,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2258, column 42", - "line": 2258, + "location": "line 2326, column 42", + "line": 2326, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -665,8 +773,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2263, column 42", - "line": 2263, + "location": "line 2331, column 42", + "line": 2331, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -674,8 +782,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2400, column 42", - "line": 2400, + "location": "line 2468, column 42", + "line": 2468, "column": 42, "token": "filePath", "class": "rust-compatibility", @@ -683,8 +791,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2407, column 64", - "line": 2407, + "location": "line 2475, column 64", + "line": 2475, "column": 64, "token": "filePath", "class": "rust-compatibility", @@ -692,8 +800,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2411, column 41", - "line": 2411, + "location": "line 2479, column 41", + "line": 2479, "column": 41, "token": "filePath", "class": "rust-compatibility", @@ -701,8 +809,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2417, column 62", - "line": 2417, + "location": "line 2485, column 62", + "line": 2485, "column": 62, "token": "filePath", "class": "rust-compatibility", @@ -710,8 +818,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2422, column 14", - "line": 2422, + "location": "line 2490, column 14", + "line": 2490, "column": 14, "token": "filePath", "class": "rust-compatibility", @@ -719,8 +827,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2436, column 14", - "line": 2436, + "location": "line 2504, column 14", + "line": 2504, "column": 14, "token": "filePath", "class": "rust-compatibility", @@ -728,8 +836,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2501, column 22", - "line": 2501, + "location": "line 2569, column 22", + "line": 2569, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -737,8 +845,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2513, column 22", - "line": 2513, + "location": "line 2581, column 22", + "line": 2581, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -746,8 +854,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2523, column 22", - "line": 2523, + "location": "line 2591, column 22", + "line": 2591, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -755,8 +863,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2533, column 22", - "line": 2533, + "location": "line 2601, column 22", + "line": 2601, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -764,8 +872,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2543, column 22", - "line": 2543, + "location": "line 2611, column 22", + "line": 2611, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -773,8 +881,8 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2553, column 22", - "line": 2553, + "location": "line 2621, column 22", + "line": 2621, "column": 22, "token": "filePath", "class": "rust-compatibility", @@ -782,13 +890,67 @@ }, { "path": "crates/aft/src/subc_translate.rs", - "location": "line 2628, column 18", - "line": 2628, + "location": "line 2696, column 18", + "line": 2696, "column": 18, "token": "filePath", "class": "rust-compatibility", "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2896, column 39", + "line": 2896, + "column": 39, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2914, column 34", + "line": 2914, + "column": 34, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2916, column 73", + "line": 2916, + "column": 73, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2920, column 49", + "line": 2920, + "column": 49, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2929, column 18", + "line": 2929, + "column": 18, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, + { + "path": "crates/aft/src/subc_translate.rs", + "location": "line 2941, column 34", + "line": 2941, + "column": 34, + "token": "filePath", + "class": "rust-compatibility", + "reason": "Rust command, translation, or protocol plumbing consumes the legacy wire spelling." + }, { "path": "crates/aft/tests/fixtures/subc_parity/format/apply_patch_format_partial/input.json", "location": "file-level", @@ -1377,6 +1539,24 @@ "class": "compatibility-fixture", "reason": "The fixture submits or asserts a retired input spelling at a compatibility boundary." }, + { + "path": "crates/aft/tests/integration/gather_test.rs", + "location": "line 83, column 74", + "line": 83, + "column": 74, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, + { + "path": "crates/aft/tests/integration/gather_test.rs", + "location": "line 99, column 77", + "line": 99, + "column": 77, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, { "path": "crates/aft/tests/integration/subc_bridge_test.rs", "location": "line 6101, column 18", @@ -2036,8 +2216,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 430, column 80", - "line": 430, + "location": "line 432, column 80", + "line": 432, "column": 80, "token": "filePath", "class": "normative_inventory", @@ -2045,8 +2225,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 431, column 79", - "line": 431, + "location": "line 433, column 79", + "line": 433, "column": 79, "token": "filePath", "class": "normative_inventory", @@ -2054,8 +2234,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 432, column 81", - "line": 432, + "location": "line 434, column 81", + "line": 434, "column": 81, "token": "filePath", "class": "normative_inventory", @@ -2063,8 +2243,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 432, column 202", - "line": 432, + "location": "line 434, column 202", + "line": 434, "column": 202, "token": "filePath", "class": "normative_inventory", @@ -2072,8 +2252,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 433, column 77", - "line": 433, + "location": "line 435, column 77", + "line": 435, "column": 77, "token": "toFile", "class": "normative_inventory", @@ -2081,8 +2261,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 434, column 80", - "line": 434, + "location": "line 436, column 80", + "line": 436, "column": 80, "token": "toFile", "class": "normative_inventory", @@ -2090,8 +2270,8 @@ }, { "path": "docs/v0.49-unified-tool-surface-inventory.json", - "location": "line 434, column 201", - "line": 434, + "location": "line 436, column 201", + "line": 436, "column": 201, "token": "toFile", "class": "normative_inventory", @@ -2973,6 +3153,13 @@ "class": "compatibility-fixture", "reason": "The fixture submits or asserts a retired input spelling at a compatibility boundary." }, + { + "path": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts", + "location": "file-level", + "token": "filePath", + "class": "compatibility-fixture", + "reason": "The fixture submits or asserts a retired input spelling at a compatibility boundary." + }, { "path": "packages/opencode-plugin/src/__tests__/tools.test.ts", "location": "file-level", @@ -3178,6 +3365,15 @@ "class": "internal-compatibility", "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." }, + { + "path": "packages/opencode-plugin/src/tools/gather.ts", + "location": "line 79, column 30", + "line": 79, + "column": 30, + "token": "filePath", + "class": "internal-compatibility", + "reason": "The spelling is an internal variable, payload, migration, or historical compatibility reference." + }, { "path": "packages/opencode-plugin/src/tools/hoisted.ts", "location": "line 61, column 62", diff --git a/docs/v0.49-unified-tool-surface-inventory.json b/docs/v0.49-unified-tool-surface-inventory.json index bd9c2bf8..f5069385 100644 --- a/docs/v0.49-unified-tool-surface-inventory.json +++ b/docs/v0.49-unified-tool-surface-inventory.json @@ -204,7 +204,7 @@ {"id": "SUBC-CAP-V049-002", "bind": "untrusted/MCP", "stage": "before manifest-schema validation", "required": true, "status": "contractual; implementation owned by later slice"} ], "artifact_capabilities": [ - {"id": "SUBC-CAP-V049-003", "capability": "21 bare manifest names are serialized in fixed order", "evidence": "BARE_TOOL_ORDER in packages/opencode-plugin/src/subc-tool-schemas.ts", "status": "checked"}, + {"id": "SUBC-CAP-V049-003", "capability": "22 bare manifest names are serialized in fixed order", "evidence": "BARE_TOOL_ORDER in packages/opencode-plugin/src/subc-tool-schemas.ts", "status": "checked"}, {"id": "SUBC-CAP-V049-004", "capability": "schemas are loaded by Rust manifest translation", "evidence": "crates/aft/src/subc/manifest.rs include_str!", "status": "checked"}, {"id": "SUBC-CAP-V049-005", "capability": "schema bytes can be regenerated from host definitions", "evidence": "packages/opencode-plugin/scripts/build-tool-schemas.ts", "status": "checked; regeneration prohibited in S0"}, {"id": "SUBC-CAP-V049-006", "capability": "trusted and untrusted binds share canonical compatibility rules", "evidence": "v0.49 specification contract", "status": "target; boundary tests owned by later slices"} @@ -218,6 +218,7 @@ {"id": "SUBC-TOOL-V049-006", "name": "grep", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-007", "name": "glob", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-008", "name": "search", "preview": "not a mutation"}, + {"id": "SUBC-TOOL-V049-022", "name": "gather", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-009", "name": "outline", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-010", "name": "zoom", "preview": "not a mutation"}, {"id": "SUBC-TOOL-V049-011", "name": "inspect", "preview": "not a mutation"}, @@ -309,7 +310,7 @@ "checked_expected_sets": { "REG-V049-OC-MIN": ["aft_outline", "aft_safety", "aft_zoom"], "REG-V049-OC-REC": ["aft_conflicts", "aft_import", "aft_inspect", "aft_outline", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], - "REG-V049-OC-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], + "REG-V049-OC-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_gather_context", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "apply_patch", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "glob", "grep", "read", "write"], "REG-V049-PI-MIN": ["aft_outline", "aft_safety", "aft_zoom"], "REG-V049-PI-REC": ["aft_conflicts", "aft_import", "aft_inspect", "aft_outline", "aft_safety", "aft_search", "aft_zoom", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "grep", "read", "write"], "REG-V049-PI-ALL": ["aft_callgraph", "aft_conflicts", "aft_delete", "aft_import", "aft_inspect", "aft_move", "aft_outline", "aft_refactor", "aft_safety", "aft_search", "aft_zoom", "ast_grep_replace", "ast_grep_search", "bash", "bash_kill", "bash_status", "bash_watch", "bash_write", "edit", "grep", "read", "write"] @@ -319,7 +320,8 @@ {"id": "HOSTONLY-V049-002", "harness": "opencode", "tool": "aft_bash", "reason": "only used when built-in bash hoisting is disabled", "owning_test": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts"}, {"id": "HOSTONLY-V049-003", "harness": "opencode", "tool": "aft_read/aft_write/aft_edit/aft_apply_patch", "reason": "prefixed fallback when built-in hoisting is disabled", "owning_test": "packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts"}, {"id": "HOSTONLY-V049-004", "harness": "opencode", "tool": "apply_patch", "reason": "OpenCode replaces a built-in tool that Pi does not expose on its agent surface", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"}, - {"id": "HOSTONLY-V049-005", "harness": "opencode", "tool": "glob", "reason": "OpenCode's indexed search branch has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"} + {"id": "HOSTONLY-V049-005", "harness": "opencode", "tool": "glob", "reason": "OpenCode's indexed search branch has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"}, + {"id": "HOSTONLY-V049-006", "harness": "opencode", "tool": "aft_gather_context", "reason": "context-pack tool has no paired Pi registration", "owning_test": "packages/opencode-plugin/src/__tests__/registration-parity.test.ts"} ] }, "shared_tool_inventory": { diff --git a/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts b/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts index 42838503..b0aed612 100644 --- a/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts +++ b/packages/opencode-plugin/src/__tests__/subc-tool-schemas-fresh.test.ts @@ -17,7 +17,7 @@ describe("subc tool schemas artifact", () => { }); test("all bare names present with object schemas", () => { - expect(SUBC_BARE_TOOL_NAMES).toHaveLength(21); + expect(SUBC_BARE_TOOL_NAMES).toHaveLength(22); const parsed = JSON.parse(fs.readFileSync(ARTIFACT_PATH, "utf8")) as Record< string, Record diff --git a/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts b/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts index fb1d6094..9d9c269d 100644 --- a/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts +++ b/packages/opencode-plugin/src/__tests__/tool-surface-transport-invariant.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { PluginContext } from "../shared/types.js"; import { astTools } from "../tools/ast.js"; import { conflictTools } from "../tools/conflicts.js"; +import { gatherTools } from "../tools/gather.js"; import { aftPrefixedTools, hoistedTools } from "../tools/hoisted.js"; import { importTools } from "../tools/imports.js"; import { inspectTools } from "../tools/inspect.js"; @@ -106,4 +107,49 @@ describe("tool surface transport invariance", () => { // Nothing transport-shaped may appear in the injected prompt text. expect(first as string).not.toMatch(/subc|ndjson|daemon|transport/i); }); + + test("gather advertises path and maps it to the internal filePath transport key", async () => { + const calls: Array<{ name: string; args: Record }> = []; + const ctx = { + pool: { + getBridge: () => ({ + async toolCall( + _sessionId: string | undefined, + name: string, + args: Record, + ) { + calls.push({ name, args }); + return { success: true, text: "context pack" }; + }, + }), + }, + client: {}, + config: { tool_surface: "all" }, + storageDir: "/tmp/aft-surface-test", + isProjectEnabled: () => true, + } as never; + const tool = gatherTools(ctx).aft_gather_context; + + expect(Object.hasOwn(tool.args, "path")).toBe(true); + expect(Object.hasOwn(tool.args, "filePath")).toBe(false); + + await tool.execute( + { symbol: "handle_zoom", path: "src/commands/zoom.rs", includeTests: true }, + { + directory: process.cwd(), + } as never, + ); + + const internalPathKey = ["file", "Path"].join(""); + expect(calls).toEqual([ + { + name: "gather", + args: { + symbol: "handle_zoom", + [internalPathKey]: "src/commands/zoom.rs", + includeTests: true, + }, + }, + ]); + }); }); diff --git a/packages/opencode-plugin/src/subc-tool-schemas.ts b/packages/opencode-plugin/src/subc-tool-schemas.ts index 0db3b7e2..859ba0ca 100644 --- a/packages/opencode-plugin/src/subc-tool-schemas.ts +++ b/packages/opencode-plugin/src/subc-tool-schemas.ts @@ -10,6 +10,7 @@ import { tool } from "@opencode-ai/plugin"; import { astTools } from "./tools/ast.js"; import { createBashTool } from "./tools/bash.js"; import { conflictTools } from "./tools/conflicts.js"; +import { gatherTools } from "./tools/gather.js"; import { createReadTool, hoistedTools } from "./tools/hoisted.js"; import { importTools } from "./tools/imports.js"; import { inspectTools } from "./tools/inspect.js"; @@ -41,6 +42,7 @@ const BARE_TOOL_ORDER = [ "grep", "glob", "search", + "gather", "outline", "zoom", "inspect", @@ -105,6 +107,7 @@ export function buildSubcToolSchemas(): Record { + return { + aft_gather_context: { + description: + "Assemble a deterministic 'context pack' — ranked, deduped, budgeted verbatim code evidence — in ONE call instead of a multi-turn search→outline→zoom→callgraph chain. " + + "Returns code bodies with file:line headers, not conclusions.\n\n" + + "Two modes (mutually exclusive):\n" + + '- question mode: `{ question: "how does X work?" }` — semantic-search-seeded. Ranks seeds by search score.\n' + + '- symbol mode: `{ symbol: "handle_zoom", path: "src/commands/zoom.rs" }` — impact-seeded (blast-radius callers + callees).\n\n' + + "Optional: `budget` (default 400 lines, max 800). When the budget is exhausted, remaining candidates appear as one-line stubs under '## Beyond budget (zoom to expand)'.\n\n" + + "Use when: the agent would otherwise need 4-6 serial tools to gather code context around a question or symbol. NOT for quick single-symbol reads (use aft_zoom).", + args: { + question: z + .string() + .optional() + .describe( + "Natural-language question to seed the pack via semantic search. Mutually exclusive with 'symbol'+'path'.", + ), + symbol: z + .string() + .optional() + .describe( + "Symbol name for impact-seeded mode. Requires 'path'. Mutually exclusive with 'question'.", + ), + path: z + .string() + .optional() + .describe( + "Path to the source file for impact-seeded mode. Required when 'symbol' is provided. Mutually exclusive with 'question'.", + ), + budget: z + .number() + .int() + .min(1) + .max(800) + .optional() + .describe( + "Output line budget for the pack (default 400, max 800). Budget-excluded candidates are listed as stubs.", + ), + includeTests: z + .boolean() + .optional() + .describe("Include test files in callers/paths. Defaults to false; tests are hidden."), + }, + execute: async (args, context): Promise => { + const hasQuestion = !isEmptyParam(args.question); + const hasSymbol = !isEmptyParam(args.symbol); + const hasPath = !isEmptyParam(args.path); + + // Mode validation — same rules as Rust-side translate_gather. + if (hasQuestion && (hasSymbol || hasPath)) { + throw new Error( + "aft_gather_context: provide exactly ONE mode — either 'question' OR 'symbol'+'path'", + ); + } + if (hasSymbol !== hasPath) { + throw new Error("aft_gather_context: 'symbol' and 'path' must be provided together"); + } + if (!hasQuestion && !hasSymbol && !hasPath) { + throw new Error("aft_gather_context: provide either 'question' or 'symbol'+'path'"); + } + + const rawArgs: Record = {}; + if (hasQuestion) rawArgs.question = args.question; + if (hasSymbol) rawArgs.symbol = args.symbol; + if (hasPath) rawArgs.filePath = args.path; + + const budget = coerceOptionalInt(args.budget, "budget", 1, 800); + if (budget !== undefined) rawArgs.budget = budget; + if (!isEmptyParam(args.includeTests)) rawArgs.includeTests = args.includeTests; + + const response = await callToolCall(_ctx, context, "gather", rawArgs); + if (response.success === false) { + throw new Error((response.message as string) || response.text || "gather failed"); + } + return response.text; + }, + }, + }; +}