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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ instant cold starts and stays fresh via file watcher and mtime verification.
- **Bash hoisting** — replaces the host's built-in `bash` with an AFT-backed shell that supports rewriter rules (`cat` → `read`, `grep` → `grep` tool, `cat >>` → edit append), per-command output compression (`git`/`cargo`/`npm`/`bun`/`pnpm`/`pytest`/`tsc`), background tasks via `background: true` with `bash_status`/`bash_kill` for control, and tree-sitter-based permission scanning (OpenCode)
- **Inline diagnostics** — write and edit return LSP errors detected after the change
- **UI metadata** — diff previews (`+N/-N`) and file paths surface in the harness UI (OpenCode desktop, Pi terminal renderer)
- **Local tool discovery** — finds biome, prettier, tsc, pyright in `node_modules/.bin` automatically
- **Local tool discovery** — finds biome, oxfmt, prettier, tsc, pyright in `node_modules/.bin` automatically

---

Expand All @@ -336,7 +336,7 @@ from "ran but partial":
- `scope_warnings`, `no_files_matched_scope` — paths/globs that resolved to zero files
- **Side-effect skips** — when the main work succeeded but a non-essential post-step was
skipped, the response carries a `<step>_skipped_reason`. Approved values:
- `format_skipped_reason`: `unsupported_language` | `no_formatter_configured` | `formatter_not_installed` | `timeout` | `error`
- `format_skipped_reason`: `unsupported_language` | `no_formatter_configured` | `formatter_not_installed` | `formatter_excluded_path` | `timeout` | `error`
- `validate_skipped_reason`: `unsupported_language` | `no_checker_configured` | `checker_not_installed` | `timeout` | `error`

### Hoisted tools
Expand Down Expand Up @@ -424,7 +424,7 @@ doesn't exist. Backs up any existing content before overwriting.
```

Returns inline LSP diagnostics if type errors are introduced. Auto-formats using the project's
configured formatter (biome, prettier, etc.).
configured formatter (biome, oxfmt, prettier, etc.).

For partial edits (find/replace), use `edit` instead.

Expand Down Expand Up @@ -1262,7 +1262,7 @@ The schema is identical across harnesses. Only file location differs.

// Per-language formatter overrides (auto-detected from project config files if omitted)
// Keys: "typescript", "python", "rust", "go"
// Values: "biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" | "none"
// Values: "biome" | "oxfmt" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" | "none"
"formatter": {
"typescript": "biome",
"rust": "rustfmt"
Expand Down Expand Up @@ -1416,8 +1416,9 @@ The schema is identical across harnesses. Only file location differs.
```

AFT auto-detects the formatter and checker from project config files (`biome.json` → biome,
`.prettierrc` → prettier, `Cargo.toml` → rustfmt, `pyproject.toml` → ruff/black, `go.mod` →
goimports). Local tool binaries (biome, prettier, tsc, pyright) are discovered in
`.oxfmtrc.json` / `.oxfmtrc.jsonc` / `oxfmt.config.ts` → oxfmt, `.prettierrc` → prettier,
`Cargo.toml` → rustfmt, `pyproject.toml` → ruff/black, `go.mod` → goimports). Local tool binaries
(biome, oxfmt, prettier, tsc, pyright) are discovered in
`node_modules/.bin` before falling back to the system PATH. You only need per-language overrides
if auto-detection picks the wrong tool or you want to pin a specific formatter.

Expand Down
1 change: 1 addition & 0 deletions assets/aft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"type": "string",
"enum": [
"biome",
"oxfmt",
"prettier",
"deno",
"ruff",
Expand Down
61 changes: 60 additions & 1 deletion crates/aft/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,8 @@ fn configure_tool_candidate(tool: &str, source: &str, required: bool) -> Configu
fn explicit_formatter_candidate(name: &str) -> Vec<ConfigureToolCandidate> {
match name {
"none" | "off" | "false" => Vec::new(),
"biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" => {
"biome" | "oxfmt" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports"
| "gofmt" => {
vec![configure_tool_candidate(name, "formatter config", true)]
}
_ => Vec::new(),
Expand Down Expand Up @@ -563,6 +564,11 @@ fn formatter_candidates(
LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
vec![configure_tool_candidate("biome", "biome.json", true)]
} else if has_project_config(
project_root,
&[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
) {
vec![configure_tool_candidate("oxfmt", "oxfmt config", true)]
} else if has_project_config(
project_root,
&[
Expand Down Expand Up @@ -2049,6 +2055,59 @@ mod tests {
assert_eq!(ctx.cache_role(), "main");
}

#[test]
fn configure_missing_tools_warns_for_explicit_oxfmt_formatter() {
let temp = tempfile::tempdir().unwrap();
let mut config = Config {
project_root: Some(temp.path().to_path_buf()),
..Config::default()
};
config
.formatter
.insert("typescript".to_string(), "oxfmt".to_string());
let candidates = super::formatter_candidates(crate::parser::LangId::TypeScript, &config);
assert_eq!(candidates.len(), 1);
let mut tool_cache = std::collections::HashMap::from([("oxfmt".to_string(), false)]);
let warning = super::missing_tool_warning(
"formatter_not_installed",
"typescript",
&candidates[0],
config.project_root.as_deref(),
&mut tool_cache,
)
.expect("expected missing oxfmt warning");

assert_eq!(warning.kind, "formatter_not_installed");
assert_eq!(warning.language, "typescript");
assert_eq!(warning.tool, "oxfmt");
}

#[test]
fn configure_missing_tools_warns_for_oxfmt_project_config() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join(".oxfmtrc.json"), "{}\n").unwrap();
let config = Config {
project_root: Some(temp.path().to_path_buf()),
..Config::default()
};

let candidates = super::formatter_candidates(crate::parser::LangId::TypeScript, &config);
assert_eq!(candidates.len(), 1);
let mut tool_cache = std::collections::HashMap::from([("oxfmt".to_string(), false)]);
let warning = super::missing_tool_warning(
"formatter_not_installed",
"typescript",
&candidates[0],
config.project_root.as_deref(),
&mut tool_cache,
)
.expect("expected missing oxfmt warning");

assert_eq!(warning.kind, "formatter_not_installed");
assert_eq!(warning.language, "typescript");
assert_eq!(warning.tool, "oxfmt");
}

/// Shared mutex serializing the home-root tests below. Both tests
/// mutate process-global `HOME` / `USERPROFILE` env vars, and `cargo
/// test` runs unit tests concurrently within the same process — without
Expand Down
2 changes: 1 addition & 1 deletion crates/aft/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ pub struct Config {
/// When "syntax", only tree-sitter parse check. When "full", runs type checker.
pub validate_on_edit: Option<String>,
/// Per-language formatter overrides. Keys: "typescript", "python", "rust", "go".
/// Values: "biome", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
/// Values: "biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
pub formatter: HashMap<String, String>,
/// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go".
/// Values: "tsc", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none".
Expand Down
85 changes: 85 additions & 0 deletions crates/aft/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,16 @@ fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<To
],
required: true,
}]
} else if has_project_config(
project_root,
&[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
) {
vec![ToolCandidate {
tool: "oxfmt".to_string(),
source: "oxfmt config".to_string(),
args: vec!["--write".to_string(), file_str.to_string()],
required: true,
}]
} else if has_project_config(
project_root,
&[
Expand Down Expand Up @@ -717,6 +727,12 @@ fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate
],
required: true,
}],
"oxfmt" => vec![ToolCandidate {
tool: name.to_string(),
source: "formatter config".to_string(),
args: vec!["--write".to_string(), file_str.to_string()],
required: true,
}],
"prettier" => vec![ToolCandidate {
tool: name.to_string(),
source: "formatter config".to_string(),
Expand Down Expand Up @@ -929,6 +945,7 @@ pub(crate) fn install_hint(tool: &str) -> String {
"biome" => {
"Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
}
"oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
"prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
"tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
"pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
Expand Down Expand Up @@ -1093,6 +1110,8 @@ fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
/// signal:
/// - biome: `"No files were processed in the specified paths."`,
/// `"ignored by the configuration"`
/// - oxfmt: `"Expected at least one target file"`,
/// `"No files found matching the given patterns"`
/// - prettier: `"No files matching the pattern were found"`
/// - ruff: `"No Python files found under the given path(s)"`
///
Expand All @@ -1104,6 +1123,8 @@ fn formatter_excluded_path(stderr: &str) -> bool {
let s = stderr.to_lowercase();
s.contains("no files were processed")
|| s.contains("ignored by the configuration")
|| s.contains("expected at least one target file")
|| s.contains("no files found matching the given patterns")
|| s.contains("no files matching the pattern")
|| s.contains("no python files found")
}
Expand Down Expand Up @@ -1814,6 +1835,32 @@ mod tests {
);
}

#[cfg(unix)]
#[test]
fn detect_formatter_oxfmt_config_for_typescript_projects() {
let _guard = tool_cache_test_lock();
clear_tool_cache();
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
let bin_dir = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("oxfmt");
fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();

let path = dir.path().join("src/app.ts");
let config = Config {
project_root: Some(dir.path().to_path_buf()),
..Config::default()
};

let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
assert_eq!(args[0], "--write");
assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
}

// Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
// without trying Windows extensions (.cmd/.exe/.bat). Writing
// `biome.cmd` would not be found by the resolver. A future product
Expand Down Expand Up @@ -1846,6 +1893,33 @@ mod tests {
assert!(args.contains(&"--write".to_string()));
}

#[cfg(unix)]
#[test]
fn detect_formatter_explicit_oxfmt_override() {
let _guard = tool_cache_test_lock();
clear_tool_cache();
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
use std::os::unix::fs::PermissionsExt;
let fake = bin_dir.join("oxfmt");
fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();

let path = Path::new("test.ts");
let mut config = Config {
project_root: Some(dir.path().to_path_buf()),
..Config::default()
};
config
.formatter
.insert("typescript".to_string(), "oxfmt".to_string());

let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
}

#[test]
fn resolve_tool_caches_positive_result_until_clear() {
let _guard = tool_cache_test_lock();
Expand Down Expand Up @@ -1939,6 +2013,16 @@ mod tests {
);
}

#[test]
fn formatter_excluded_path_detects_oxfmt_messages() {
assert!(formatter_excluded_path(
"Expected at least one target file. All matched files may have been excluded by ignore rules."
));
assert!(formatter_excluded_path(
"No files found matching the given patterns."
));
}

#[test]
fn formatter_excluded_path_detects_ruff_messages() {
// Real ruff output when invoked outside its [tool.ruff] scope.
Expand All @@ -1953,6 +2037,7 @@ mod tests {
fn formatter_excluded_path_is_case_insensitive() {
assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
assert!(formatter_excluded_path("Ignored By The Configuration"));
assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
}

#[test]
Expand Down
81 changes: 81 additions & 0 deletions crates/aft/tests/integration/format_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,87 @@ fn format_integration_formatter_not_installed() {
assert!(status.success());
}

#[cfg(unix)]
#[test]
fn format_integration_oxfmt_config_runs_oxfmt() {
let dir = format_test_dir("oxfmt_config_runs");
fs::write(dir.join(".oxfmtrc.json"), "{}\n").unwrap();
let bin_dir = dir.join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
let stub = bin_dir.join("oxfmt");
fs::write(
&stub,
"#!/bin/sh\nif [ \"$1\" = \"--write\" ]; then printf 'const x = 1;\n' > \"$2\"; fi\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();

let target = dir.join("format_oxfmt.ts");
let _ = fs::remove_file(&target);
let path = prepend_path(&std::ffi::OsString::new(), &dir);
let mut aft = AftProcess::spawn_with_env(&[("PATH", path.as_os_str())]);
let cfg = aft.configure(&dir);
assert_eq!(cfg["success"], true, "configure should succeed: {:?}", cfg);

let resp = aft.send(&format!(
r#"{{"id":"fmt-3c","command":"write","file":"{}","content":"const x=1;\n"}}"#,
target.display()
));

assert_eq!(resp["success"], true, "write should succeed: {:?}", resp);
assert_eq!(
resp["formatted"], true,
"oxfmt should have formatted the file"
);
assert_eq!(fs::read_to_string(&target).unwrap(), "const x = 1;\n");

let _ = fs::remove_file(&target);
let status = aft.shutdown();
assert!(status.success());
}

#[cfg(unix)]
#[test]
fn format_integration_oxfmt_ignored_path_reports_formatter_excluded_path() {
let dir = format_test_dir("oxfmt_ignored_path");
fs::write(dir.join(".oxfmtrc.json"), "{}\n").unwrap();
let bin_dir = dir.join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
let stub = bin_dir.join("oxfmt");
fs::write(
&stub,
"#!/bin/sh\nprintf 'Expected at least one target file after applying ignore rules.\n' >&2\nexit 1\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();

let target = dir.join("format_oxfmt_ignored.ts");
let _ = fs::remove_file(&target);
let path = prepend_path(&std::ffi::OsString::new(), &dir);
let mut aft = AftProcess::spawn_with_env(&[("PATH", path.as_os_str())]);
let cfg = aft.configure(&dir);
assert_eq!(cfg["success"], true, "configure should succeed: {:?}", cfg);

let resp = aft.send(&format!(
r#"{{"id":"fmt-3d","command":"write","file":"{}","content":"const x=1;\n"}}"#,
target.display()
));

assert_eq!(resp["success"], true, "write should succeed: {:?}", resp);
assert_eq!(resp["formatted"], false);
assert_eq!(
resp["format_skipped_reason"], "formatter_excluded_path",
"oxfmt ignored paths should report formatter_excluded_path: {:?}",
resp
);

let _ = fs::remove_file(&target);
let status = aft.shutdown();
assert!(status.success());
}

/// add_import on a .rs file → verify response has formatted field.

#[test]
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-plugin/scripts/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const SCHEMA_URL = "https://raw.githubusercontent.com/cortexkit/aft/master/asset
function buildSchema(): Record<string, unknown> {
const formatterEnum = {
type: "string",
enum: ["biome", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none"],
enum: ["biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none"],
};

const checkerEnum = {
Expand Down
Loading
Loading