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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 138 additions & 3 deletions crates/aft/src/bash_rewrite/rules.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,9 +35,14 @@ impl RewriteRule for GrepRule {
ctx: &AppContext,
) -> Result<Response, String> {
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(),
)
}
}
Expand All @@ -55,9 +63,14 @@ impl RewriteRule for RgRule {
ctx: &AppContext,
) -> Result<Response, String> {
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(),
)
}
}
Expand Down Expand Up @@ -204,15 +217,62 @@ fn try_call_and_footer(response: Response, replacement_tool: &str) -> Result<Res

/// Grep/rg variant: same decline handling, but the footer is the enforced
/// code-search redirect, steering to `aft_search` when it's registered.
fn try_call_and_grep_footer(response: Response, ctx: &AppContext) -> Result<Response, String> {
fn try_call_and_grep_footer(
response: Response,
ctx: &AppContext,
path: Option<&str>,
) -> Result<Response, String> {
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<String> {
if response.success {
return None;
Expand Down Expand Up @@ -502,9 +562,84 @@ fn ls_request(command: &str) -> Option<Value> {

#[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() {
Expand Down
21 changes: 21 additions & 0 deletions crates/aft/tests/integration/bash_rewrite_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down Expand Up @@ -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 =
Expand Down
75 changes: 75 additions & 0 deletions packages/aft-bridge/src/__tests__/bash-hints.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/aft-bridge/src/bash-hints.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode-plugin/src/__tests__/e2e/bash.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference path="../../bun-test.d.ts" />

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";
Expand Down Expand Up @@ -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")}`,
Expand Down
Loading