diff --git a/README.md b/README.md index 1e4a979d..8a3bc6bb 100644 --- a/README.md +++ b/README.md @@ -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 --- @@ -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 `_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 @@ -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. @@ -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" @@ -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. diff --git a/assets/aft.schema.json b/assets/aft.schema.json index 3f43f5bb..70bc1848 100644 --- a/assets/aft.schema.json +++ b/assets/aft.schema.json @@ -34,6 +34,7 @@ "type": "string", "enum": [ "biome", + "oxfmt", "prettier", "deno", "ruff", diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 2760cb0d..922727c8 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -533,7 +533,8 @@ fn configure_tool_candidate(tool: &str, source: &str, required: bool) -> Configu fn explicit_formatter_candidate(name: &str) -> Vec { 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(), @@ -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, &[ @@ -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 diff --git a/crates/aft/src/config.rs b/crates/aft/src/config.rs index 2059e27a..3b576c45 100644 --- a/crates/aft/src/config.rs +++ b/crates/aft/src/config.rs @@ -103,7 +103,7 @@ pub struct Config { /// When "syntax", only tree-sitter parse check. When "full", runs type checker. pub validate_on_edit: Option, /// 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, /// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go". /// Values: "tsc", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none". diff --git a/crates/aft/src/format.rs b/crates/aft/src/format.rs index b038567d..120b34f2 100644 --- a/crates/aft/src/format.rs +++ b/crates/aft/src/format.rs @@ -487,6 +487,16 @@ fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec Vec 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(), @@ -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(), @@ -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)"` /// @@ -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") } @@ -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/` // without trying Windows extensions (.cmd/.exe/.bat). Writing // `biome.cmd` would not be found by the resolver. A future product @@ -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(); @@ -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. @@ -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] diff --git a/crates/aft/tests/integration/format_test.rs b/crates/aft/tests/integration/format_test.rs index a7f1aa18..1358314a 100644 --- a/crates/aft/tests/integration/format_test.rs +++ b/crates/aft/tests/integration/format_test.rs @@ -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] diff --git a/packages/opencode-plugin/scripts/build-schema.ts b/packages/opencode-plugin/scripts/build-schema.ts index 940df8e7..e8849ebc 100644 --- a/packages/opencode-plugin/scripts/build-schema.ts +++ b/packages/opencode-plugin/scripts/build-schema.ts @@ -23,7 +23,7 @@ const SCHEMA_URL = "https://raw.githubusercontent.com/cortexkit/aft/master/asset function buildSchema(): Record { 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 = { diff --git a/packages/opencode-plugin/src/__tests__/config.test.ts b/packages/opencode-plugin/src/__tests__/config.test.ts index 321bee41..206a7b4b 100644 --- a/packages/opencode-plugin/src/__tests__/config.test.ts +++ b/packages/opencode-plugin/src/__tests__/config.test.ts @@ -149,6 +149,12 @@ describe("loadAftConfig", () => { expect(result.stderr).toContain(`Config loaded from ${fixture.projectConfigPath}`); }); + test("accepts oxfmt formatter in config schema", () => { + expect(AftConfigSchema.parse({ formatter: { typescript: "oxfmt" } }).formatter).toEqual({ + typescript: "oxfmt", + }); + }); + // Audit v0.17 #17: project config CANNOT set `restrict_to_project_root`, // `url_fetch_allow_private`, or `max_callgraph_files`. These are user-only // because a hostile repo opening in OpenCode could otherwise weaken the diff --git a/packages/opencode-plugin/src/config.ts b/packages/opencode-plugin/src/config.ts index e914fcb7..320c2b37 100644 --- a/packages/opencode-plugin/src/config.ts +++ b/packages/opencode-plugin/src/config.ts @@ -11,6 +11,7 @@ import { error, log, warn } from "./logger.js"; const FormatterEnum = z.enum([ "biome", + "oxfmt", "prettier", "deno", "ruff", diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 564dfd5a..adee66f0 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -27,7 +27,7 @@ Pi's default `read`, `write`, `edit`, and `grep` are replaced with AFT-backed ve | Tool | Pi built-in | AFT replacement | | ------- | ------------------------ | -------------------------------------------------------------------------------------------- | | `read` | Node `fs.readFile` | Rust reader with line-numbered output, directory listing, binary/image detection | -| `write` | Node `fs.writeFile` | Atomic write with per-file backup, auto-format (biome/prettier/ruff/rustfmt), LSP diagnostics | +| `write` | Node `fs.writeFile` | Atomic write with per-file backup, auto-format (biome/oxfmt/prettier/ruff/rustfmt), LSP diagnostics | | `edit` | Plain substring replace | Progressive fuzzy match (handles whitespace/Unicode drift), backups, glob-wide edits | | `grep` | ripgrep shell-out | Trigram-indexed search in-project, ripgrep fallback outside project root | diff --git a/packages/pi-plugin/src/__tests__/config.test.ts b/packages/pi-plugin/src/__tests__/config.test.ts index 0194a4c7..9d0781c0 100644 --- a/packages/pi-plugin/src/__tests__/config.test.ts +++ b/packages/pi-plugin/src/__tests__/config.test.ts @@ -567,6 +567,12 @@ describe("loadAftConfig", () => { expect(AftConfigSchema.safeParse({ formatter_timeout_secs: 0 }).success).toBe(false); }); + test("accepts oxfmt formatter in Pi config schema", () => { + expect(AftConfigSchema.parse({ formatter: { typescript: "oxfmt" } }).formatter).toEqual({ + typescript: "oxfmt", + }); + }); + test("keeps user executable-origin lsp settings when project also sets every lsp key", () => { const fixture = createConfigFixture(); writeFileSync( diff --git a/packages/pi-plugin/src/config.ts b/packages/pi-plugin/src/config.ts index aedb2bd3..6175cdb9 100644 --- a/packages/pi-plugin/src/config.ts +++ b/packages/pi-plugin/src/config.ts @@ -11,6 +11,7 @@ import { error, log, warn } from "./logger.js"; export type Formatter = | "biome" + | "oxfmt" | "prettier" | "deno" | "ruff" @@ -123,6 +124,7 @@ export interface AftConfig { const FormatterEnum = z.enum([ "biome", + "oxfmt", "prettier", "deno", "ruff",