diff --git a/crates/aft/src/bash_rewrite/rules.rs b/crates/aft/src/bash_rewrite/rules.rs index 1fa09aa1..7f6817a7 100644 --- a/crates/aft/src/bash_rewrite/rules.rs +++ b/crates/aft/src/bash_rewrite/rules.rs @@ -1,6 +1,9 @@ use serde_json::{json, Value}; +use std::path::Path; +use std::time::{Duration, SystemTime}; const REGEX_SIZE_LIMIT: usize = 10 * 1024 * 1024; +const GREP_FOOTER_FRESHNESS_WINDOW: Duration = Duration::from_secs(60); use crate::bash_rewrite::footer::{add_footer, add_grep_footer}; use crate::bash_rewrite::parser::parse; @@ -32,9 +35,14 @@ impl RewriteRule for GrepRule { ctx: &AppContext, ) -> Result { let params = grep_request(command, "grep").ok_or("not a grep rewrite")?; + let path = params + .get("path") + .and_then(Value::as_str) + .map(str::to_owned); try_call_and_grep_footer( crate::commands::grep::handle_grep(&request("grep", params, session_id), ctx), ctx, + path.as_deref(), ) } } @@ -55,9 +63,14 @@ impl RewriteRule for RgRule { ctx: &AppContext, ) -> Result { let params = grep_request(command, "rg").ok_or("not an rg rewrite")?; + let path = params + .get("path") + .and_then(Value::as_str) + .map(str::to_owned); try_call_and_grep_footer( crate::commands::grep::handle_grep(&request("grep", params, session_id), ctx), ctx, + path.as_deref(), ) } } @@ -204,15 +217,62 @@ fn try_call_and_footer(response: Response, replacement_tool: &str) -> Result Result { +fn try_call_and_grep_footer( + response: Response, + ctx: &AppContext, + path: Option<&str>, +) -> Result { if let Some(err) = declined_error(&response, "grep") { return Err(err); } let output = response_output(&response.data); - let footered = add_grep_footer(&output, ctx.config().aft_search_registered); + let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) { + output + } else { + add_grep_footer(&output, ctx.config().aft_search_registered) + }; Ok(apply_footer(response, footered)) } +fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf { + let configured = ctx + .config() + .project_root + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + std::fs::canonicalize(&configured).unwrap_or(configured) +} + +fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool { + let Some(path) = path else { + return false; + }; + let target = Path::new(path); + let target = if target.is_absolute() { + target.to_path_buf() + } else { + project_root.join(target) + }; + let Ok(target) = std::fs::canonicalize(target) else { + return false; + }; + if !target.starts_with(project_root) { + return true; + } + let Ok(metadata) = std::fs::metadata(&target) else { + return false; + }; + if metadata.is_file() { + return true; + } + let Ok(modified) = metadata.modified() else { + return false; + }; + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW) +} + fn declined_error(response: &Response, replacement_tool: &str) -> Option { if response.success { return None; @@ -502,9 +562,84 @@ fn ls_request(command: &str) -> Option { #[cfg(test)] mod tests { + use std::fs; + use std::time::{Duration, SystemTime}; + use serde_json::json; - use super::find_request; + use super::{find_request, should_suppress_grep_footer}; + + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap(); + dir + } + + #[test] + fn single_named_file_suppresses_grep_footer() { + let dir = fixture(); + assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path())); + } + + #[test] + fn directory_path_keeps_grep_footer() { + let dir = fixture(); + filetime::set_file_mtime( + dir.path().join("src"), + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); + assert!(!should_suppress_grep_footer(Some("src"), dir.path())); + } + + #[test] + fn no_path_keeps_grep_footer() { + let dir = fixture(); + assert!(!should_suppress_grep_footer(None, dir.path())); + } + + #[test] + fn external_file_suppresses_grep_footer() { + let dir = fixture(); + let external = tempfile::NamedTempFile::new().unwrap(); + assert!(should_suppress_grep_footer( + external.path().to_str(), + dir.path() + )); + } + + #[test] + fn freshly_modified_file_suppresses_grep_footer() { + let dir = fixture(); + let file = dir.path().join("src/app.ts"); + fs::write(&file, "foo\nbar\n").unwrap(); + assert!(should_suppress_grep_footer(file.to_str(), dir.path())); + } + + #[test] + fn old_directory_inside_project_root_keeps_grep_footer() { + let dir = fixture(); + let directory = dir.path().join("src"); + filetime::set_file_mtime( + &directory, + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); + assert!(!should_suppress_grep_footer(Some("src"), dir.path())); + } + + #[test] + fn old_file_inside_project_root_suppresses_grep_footer() { + let dir = fixture(); + let file = dir.path().join("src/app.ts"); + filetime::set_file_mtime( + &file, + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); + assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path())); + } #[test] fn find_absolute_path_uses_glob_path_arg() { diff --git a/crates/aft/tests/integration/bash_rewrite_test.rs b/crates/aft/tests/integration/bash_rewrite_test.rs index 4a36b51a..4ec3993f 100644 --- a/crates/aft/tests/integration/bash_rewrite_test.rs +++ b/crates/aft/tests/integration/bash_rewrite_test.rs @@ -11,6 +11,7 @@ #![cfg(unix)] use std::fs; +use std::time::{Duration, SystemTime}; use aft::bash_rewrite::{parser, try_rewrite}; use aft::commands::edit_match::handle_edit_match; @@ -104,6 +105,11 @@ fn rewrites_grep_and_rejects_pipes() { let dir = tempfile::tempdir().unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::write(dir.path().join("src/lib.rs"), "fn Needle() {}\n").unwrap(); + filetime::set_file_mtime( + dir.path().join("src"), + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); let ctx = context(dir.path(), true); let data = rewrite( @@ -127,6 +133,16 @@ fn grep_footer_steers_to_aft_search_when_registered() { let dir = tempfile::tempdir().unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::write(dir.path().join("src/lib.rs"), "fn needle() {}\n").unwrap(); + filetime::set_file_mtime( + dir.path(), + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); + filetime::set_file_mtime( + dir.path().join("src"), + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); let target = format!("grep -ni needle {}", dir.path().join("src").display()); // Registered → footer names `aft_search`, not the grep tool. @@ -158,6 +174,11 @@ fn grep_rewrite_rejects_oversized_regex_programs() { fn rewrites_rg_and_rejects_chains() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("notes.txt"), "alpha beta\n").unwrap(); + filetime::set_file_mtime( + dir.path(), + filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)), + ) + .unwrap(); let ctx = context(dir.path(), true); let data = diff --git a/packages/aft-bridge/src/__tests__/bash-hints.test.ts b/packages/aft-bridge/src/__tests__/bash-hints.test.ts index 6542bfca..b55692f0 100644 --- a/packages/aft-bridge/src/__tests__/bash-hints.test.ts +++ b/packages/aft-bridge/src/__tests__/bash-hints.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { commandInvokesCodeSearch, maybeAppendConflictsHint, @@ -232,6 +235,78 @@ describe("maybeAppendGrepSearchHint", () => { `${output}\n\n${AFT_SEARCH_HINT}`, ); }); + + test("suppresses a piped grep targeting an in-project file", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aft-bash-hints-")); + try { + fs.mkdirSync(path.join(root, "src")); + const file = path.join(root, "src/app.ts"); + fs.writeFileSync(file, "foo\n"); + const old = new Date(Date.now() - 61_000); + fs.utimesSync(file, old, old); + expect(maybeAppendGrepSearchHint("hit", "grep -n foo src/app.ts | head -5", true, root)).toBe( + "hit", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("keeps the lecture for a piped old in-project directory search", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aft-bash-hints-")); + try { + const src = path.join(root, "src"); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, "app.ts"), "foo\n"); + const old = new Date(Date.now() - 61_000); + fs.utimesSync(src, old, old); + expect(maybeAppendGrepSearchHint("hit", "grep -n foo src | head -5", true, root)).toContain( + AFT_SEARCH_HINT, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("suppresses a piped grep targeting a fresh in-project file", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aft-bash-hints-")); + try { + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src/app.ts"), "foo\n"); + expect(maybeAppendGrepSearchHint("hit", "grep -n foo src/app.ts | head -5", true, root)).toBe( + "hit", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("keeps the lecture for a piped grep targeting a nonexistent path", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aft-bash-hints-")); + try { + expect( + maybeAppendGrepSearchHint("hit", "grep -n foo src/missing.ts | head -5", true, root), + ).toContain(AFT_SEARCH_HINT); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("suppresses a multi-operand grep when any operand is an existing file", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aft-bash-hints-")); + try { + const src = path.join(root, "src"); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, "app.ts"), "foo\n"); + const old = new Date(Date.now() - 61_000); + fs.utimesSync(src, old, old); + expect( + maybeAppendGrepSearchHint("hit", "grep -n foo src/app.ts src/ | head -5", true, root), + ).toBe("hit"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); describe("maybeAppendGrepSearchHint — redirection operand scan terminates", () => { diff --git a/packages/aft-bridge/src/bash-hints.ts b/packages/aft-bridge/src/bash-hints.ts index ee5f2ae7..50fa33ed 100644 --- a/packages/aft-bridge/src/bash-hints.ts +++ b/packages/aft-bridge/src/bash-hints.ts @@ -1,7 +1,8 @@ +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -// Pure helpers for the bash-output hint nudges appended to bash tool results. +// Helpers for the bash-output hint nudges appended to bash tool results. // // Shared across harnesses (OpenCode applies it in `tool.execute.after`; Pi // applies it inside its hoisted bash tool). Returns the new output string (or @@ -18,6 +19,7 @@ const GREP_SEARCH_GREP_HINT = "DO NOT search code by running grep/rg in bash — it is unindexed, unranked, and serial. Use the `grep` tool instead (indexed and ranked)."; const GREP_SEARCH_HINT_PREFIX = "DO NOT search code by running grep/rg in bash —"; +const GREP_SEARCH_FRESHNESS_WINDOW_MS = 60_000; type Quote = "none" | "single" | "double"; @@ -133,11 +135,16 @@ function shouldSuppressGrepSearchHint(command: string, projectRoot: string | und const operands = collectPathOperands(firstStage, firstToken.end); // No path operands → grep reads stdin or searches the (in-project) cwd. if (operands.length === 0) return false; + let sawInProjectOperand = false; for (const operand of operands) { if (isDynamicPathOperand(operand)) continue; - if (isPathInsideProject(resolvedRoot, effectiveCwd, operand)) return false; + const resolvedOperand = resolvePathOperand(effectiveCwd, operand); + if (!isPathInsideProject(resolvedRoot, effectiveCwd, operand)) continue; + sawInProjectOperand = true; + if (shouldSuppressResolvedPath(resolvedOperand)) return true; } // All operands resolve outside the project → this grep is external. + if (sawInProjectOperand) return false; } return sawCodeSearchStatement; @@ -255,6 +262,17 @@ function isPathInsideProject(resolvedRoot: string, baseCwd: string, operand: str return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } +function shouldSuppressResolvedPath(resolved: string): boolean { + try { + const stats = fs.statSync(resolved); + if (stats.isFile()) return true; + const ageMs = Date.now() - stats.mtimeMs; + return ageMs >= 0 && ageMs < GREP_SEARCH_FRESHNESS_WINDOW_MS; + } catch { + return false; + } +} + /** * Split a command into top-level statements, breaking on `&&`, `||`, `;`, `&`, * and newlines while respecting quotes, escapes, backticks, and parentheses diff --git a/packages/opencode-plugin/src/__tests__/e2e/bash.test.ts b/packages/opencode-plugin/src/__tests__/e2e/bash.test.ts index 43d4366b..fee8de22 100644 --- a/packages/opencode-plugin/src/__tests__/e2e/bash.test.ts +++ b/packages/opencode-plugin/src/__tests__/e2e/bash.test.ts @@ -1,7 +1,7 @@ /// import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { type BinaryBridge, BridgePool } from "@cortexkit/aft-bridge"; import type { ToolContext } from "@opencode-ai/plugin"; @@ -344,6 +344,8 @@ maybeDescribe("e2e bash command (OpenCode adapter + bridge + Rust)", () => { const h = await harness({ experimental_bash_rewrite: true }); await mkdir(h.path("src")); await writeFile(h.path("src", "lib.ts"), "needle\nhaystack\n", "utf8"); + const old = new Date(Date.now() - 61_000); + await utimes(h.path("src"), old, old); const response = await h.bridge.send("bash", { command: `grep -r needle ${h.path("src")}`, diff --git a/packages/pi-plugin/src/__tests__/e2e/bash.test.ts b/packages/pi-plugin/src/__tests__/e2e/bash.test.ts index ea1fa3f4..ece29f99 100644 --- a/packages/pi-plugin/src/__tests__/e2e/bash.test.ts +++ b/packages/pi-plugin/src/__tests__/e2e/bash.test.ts @@ -9,7 +9,7 @@ /// import { afterEach, beforeAll, describe, expect, test } from "bun:test"; -import { mkdir, realpath, writeFile } from "node:fs/promises"; +import { mkdir, realpath, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { type BinaryBridge, BridgePool } from "@cortexkit/aft-bridge"; import { withEnv } from "../../../../aft-bridge/src/__tests__/test-utils/env-guard.js"; @@ -306,6 +306,8 @@ maybeDescribe("e2e bash command (Pi adapter + bridge + Rust)", () => { const h = await harness({ rewrite: true }); await mkdir(h.path("src")); await writeFile(h.path("src", "lib.ts"), "needle\nhaystack\n", "utf8"); + const old = new Date(Date.now() - 61_000); + await utimes(h.path("src"), old, old); const response = await h.bridge.send("bash", { command: `grep -r needle ${h.path("src")}`,