Skip to content

Commit b171561

Browse files
committed
feat(aft): add aft_check command for on-demand type checker validation
Adds a new aft_check tool that runs the configured type checker (ruff, tsc, pyright, etc.) on a single file and returns validation errors. Intended for use after completing a batch of edits, not after every intermediate edit. Changes: - commands/check.rs: new command handler calling validate_full - subc_translate.rs: translate_check maps filePath to file - subc_format.rs: format_check_response renders check results - config.rs + config_resolve.rs: CheckConfig with check.enabled (default false); tool is only registered when enabled and checker config is non-empty - opencode-plugin: tool definition, config schema, registration
1 parent fb3210b commit b171561

10 files changed

Lines changed: 285 additions & 1 deletion

File tree

crates/aft/src/commands/check.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Handler for the `check` command: on-demand type checker validation.
2+
3+
use std::path::Path;
4+
5+
use crate::context::AppContext;
6+
use crate::format;
7+
use crate::protocol::{RawRequest, Response};
8+
9+
/// Handle a `check` request.
10+
///
11+
/// Params:
12+
/// - `file` (string, required) - absolute path to the file to check
13+
///
14+
/// Returns: `{ file, error_count, errors?, skipped_reason? }`
15+
pub fn handle_check(req: &RawRequest, ctx: &AppContext) -> Response {
16+
let file = match req.params.get("file").and_then(|v| v.as_str()) {
17+
Some(f) => f,
18+
None => {
19+
return Response::error(
20+
&req.id,
21+
"invalid_request",
22+
"check: missing required param 'file'",
23+
);
24+
}
25+
};
26+
27+
let path = match ctx.validate_path(&req.id, Path::new(file)) {
28+
Ok(p) => p,
29+
Err(resp) => return resp,
30+
};
31+
32+
if !path.is_file() {
33+
return Response::error(
34+
&req.id,
35+
"invalid_request",
36+
format!("check: path must be a file: {}", path.display()),
37+
);
38+
}
39+
40+
let config = ctx.config();
41+
let (errors, skip_reason) = format::validate_full(&path, &config);
42+
43+
let mut result = serde_json::json!({
44+
"file": file,
45+
"error_count": errors.len(),
46+
});
47+
48+
if !errors.is_empty() {
49+
result["errors"] = serde_json::json!(errors);
50+
}
51+
if let Some(reason) = skip_reason {
52+
result["skipped_reason"] = serde_json::json!(reason);
53+
}
54+
55+
Response::success(&req.id, result)
56+
}

crates/aft/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod bash_status;
1414
pub mod bash_wait_detach;
1515
pub mod bash_write;
1616
pub mod batch;
17+
pub mod check;
1718
pub mod call_tree;
1819
pub mod callers;
1920
pub mod callgraph_store_adapter;

crates/aft/src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ pub struct InspectConfig {
8686
pub duplicates: InspectDuplicatesConfig,
8787
}
8888

89+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90+
#[serde(default)]
91+
pub struct CheckConfig {
92+
pub enabled: bool,
93+
}
94+
8995
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
9096
#[serde(default)]
9197
pub struct InspectDuplicatesConfig {
@@ -193,6 +199,7 @@ pub struct Config {
193199
pub search_index_max_file_size: u64,
194200
pub semantic: SemanticBackendConfig,
195201
pub inspect: InspectConfig,
202+
pub check: CheckConfig,
196203
pub backup: BackupConfig,
197204
/// Enable Astral ty as an experimental Python LSP server (default: false).
198205
pub experimental_lsp_ty: bool,
@@ -275,6 +282,7 @@ impl Default for Config {
275282
search_index_max_file_size: 1_048_576,
276283
semantic: SemanticBackendConfig::default(),
277284
inspect: InspectConfig::default(),
285+
check: CheckConfig::default(),
278286
backup: BackupConfig::default(),
279287
experimental_lsp_ty: false,
280288
lsp_servers: Vec::new(),

crates/aft/src/config_resolve.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer};
1212
use serde_json::{Map, Value};
1313

1414
use crate::config::{
15-
BackupConfig, Config, InspectConfig, SemanticBackend, SemanticBackendConfig, UserServerDef,
15+
BackupConfig, CheckConfig, Config, InspectConfig, SemanticBackend, SemanticBackendConfig, UserServerDef,
1616
};
1717
use crate::jsonc::strip_jsonc;
1818

@@ -90,6 +90,7 @@ pub struct RawAftConfig {
9090
#[serde(deserialize_with = "deserialize_opt_usize")]
9191
pub callgraph_chunk_size: Option<usize>,
9292
pub inspect: Option<RawInspect>,
93+
pub check: Option<RawCheck>,
9394
pub backup: Option<RawBackup>,
9495
pub bash: Option<RawBash>,
9596
pub experimental: Option<RawExperimental>,
@@ -364,6 +365,18 @@ impl RawInspect {
364365
}
365366
}
366367

368+
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
369+
#[serde(default)]
370+
pub struct RawCheck {
371+
pub enabled: Option<bool>,
372+
}
373+
374+
impl RawCheck {
375+
fn is_empty(&self) -> bool {
376+
self.enabled.is_none()
377+
}
378+
}
379+
367380
/// Only `expected_mirrors` survives here: `lower_bound`, `discard_cost`, and
368381
/// `anonymize` were accepted-but-never-read knobs (the scanner hardcodes its
369382
/// cost bounds and anonymization rules), so they were removed from the schema
@@ -569,6 +582,9 @@ fn merge_trusted_config(base: &mut RawAftConfig, override_config: RawAftConfig)
569582
if override_config.inspect.is_some() {
570583
base.inspect = override_config.inspect;
571584
}
585+
if override_config.check.is_some() {
586+
base.check = override_config.check;
587+
}
572588
if override_config.backup.is_some() {
573589
base.backup = override_config.backup;
574590
}
@@ -636,6 +652,7 @@ fn merge_project_config(base: &mut RawAftConfig, project: RawAftConfig) {
636652
base.experimental = merge_experimental_config(base.experimental.clone(), project.experimental);
637653
base.bash = merge_bash_config(base.bash.clone(), project.bash);
638654
base.inspect = merge_inspect_config(base.inspect.clone(), project.inspect);
655+
base.check = merge_check_config(base.check.clone(), project.check);
639656
}
640657

641658
fn merge_formatter_map(
@@ -850,6 +867,18 @@ fn merge_inspect_config(
850867
(!inspect.is_empty()).then_some(inspect)
851868
}
852869

870+
fn merge_check_config(
871+
base: Option<RawCheck>,
872+
override_check: Option<RawCheck>,
873+
) -> Option<RawCheck> {
874+
let Some(override_check) = override_check else {
875+
return base;
876+
};
877+
let mut check = base.unwrap_or_default();
878+
check.enabled = override_check.enabled.or(check.enabled);
879+
(!check.is_empty()).then_some(check)
880+
}
881+
853882
fn merge_inspect_duplicates(
854883
base: Option<RawInspectDuplicates>,
855884
override_duplicates: Option<RawInspectDuplicates>,
@@ -990,6 +1019,7 @@ fn apply_resolved_config(raw: &RawAftConfig, config: &mut Config) {
9901019
}
9911020
config.semantic = resolve_semantic_config(raw.semantic.as_ref());
9921021
config.inspect = resolve_inspect_config(raw.inspect.as_ref());
1022+
config.check = resolve_check_config(raw.check.as_ref());
9931023
config.backup = resolve_backup_config(raw.backup.as_ref());
9941024
resolve_lsp_config(raw, config);
9951025
resolve_bash_fields(raw, config);
@@ -1044,6 +1074,16 @@ fn resolve_inspect_config(raw: Option<&RawInspect>) -> InspectConfig {
10441074
inspect
10451075
}
10461076

1077+
fn resolve_check_config(raw: Option<&RawCheck>) -> CheckConfig {
1078+
let mut check = CheckConfig::default();
1079+
if let Some(raw) = raw {
1080+
if let Some(enabled) = raw.enabled {
1081+
check.enabled = enabled;
1082+
}
1083+
}
1084+
check
1085+
}
1086+
10471087
fn resolve_backup_config(raw: Option<&RawBackup>) -> BackupConfig {
10481088
let mut backup = BackupConfig::default();
10491089
if let Some(raw) = raw {
@@ -1996,4 +2036,28 @@ mod tests {
19962036
Some("rustfmt")
19972037
);
19982038
}
2039+
2040+
#[test]
2041+
fn check_config_defaults_to_disabled() {
2042+
let result = resolve_config(&[tier("user", r#"{ "tool_surface": "all" }"#)]);
2043+
assert!(!result.config.check.enabled);
2044+
}
2045+
2046+
#[test]
2047+
fn check_config_enabled_via_user_tier() {
2048+
let result = resolve_config(&[tier(
2049+
"user",
2050+
r#"{ "check": { "enabled": true } }"#,
2051+
)]);
2052+
assert!(result.config.check.enabled);
2053+
}
2054+
2055+
#[test]
2056+
fn check_config_project_tier_overrides_user() {
2057+
let result = resolve_config(&[
2058+
tier("user", r#"{ "check": { "enabled": true } }"#),
2059+
tier("project", r#"{ "check": { "enabled": false } }"#),
2060+
]);
2061+
assert!(!result.config.check.enabled);
2062+
}
19992063
}

crates/aft/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,7 @@ fn dispatch(req: RawRequest, ctx: &AppContext) -> Response {
702702
"inline_symbol" => aft::commands::inline_symbol::handle_inline_symbol(&req, ctx),
703703
"inspect" => aft::commands::inspect::handle_inspect(&req, ctx),
704704
"inspect_tier2_run" => aft::commands::inspect::handle_inspect_tier2_run(&req, ctx),
705+
"check" => aft::commands::check::handle_check(&req, ctx),
705706
"git_conflicts" => aft::commands::conflicts::handle_git_conflicts(ctx, &req),
706707
"ast_search" => aft::commands::ast_search::handle_ast_search(&req, ctx),
707708
"ast_replace" => aft::commands::ast_replace::handle_ast_replace(&req, ctx),

crates/aft/src/subc_format.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ fn is_core_agent_tool(bare_name: &str) -> bool {
257257
| "import"
258258
| "refactor"
259259
| "safety"
260+
| "check"
260261
)
261262
}
262263

@@ -314,6 +315,7 @@ pub fn format_response_with_context(
314315
"import" => format_import(data, ctx),
315316
"refactor" => format_refactor(data, ctx),
316317
"safety" => format_safety(data, ctx),
318+
"check" => format_check_response(data),
317319
_ => unreachable!("core agent tools are exhaustive"),
318320
}
319321
}
@@ -913,6 +915,31 @@ fn format_edit_response(data: &Value) -> String {
913915
result
914916
}
915917

918+
fn format_check_response(data: &Value) -> String {
919+
let file = data
920+
.get("file")
921+
.and_then(Value::as_str)
922+
.unwrap_or("(unknown)");
923+
let count = data
924+
.get("error_count")
925+
.and_then(Value::as_u64)
926+
.unwrap_or(0) as usize;
927+
928+
if let Some(reason) = data.get("skipped_reason").and_then(Value::as_str) {
929+
return format!("Checked {file} - validation skipped ({reason}).");
930+
}
931+
932+
if count == 0 {
933+
return format!("Checked {file} - no errors found.");
934+
}
935+
936+
let mut output = format!("Checked {file} - {count} error(s):\n");
937+
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
938+
output.push_str(&format_error_lines(errors));
939+
}
940+
output
941+
}
942+
916943
fn format_glob_skip_reasons_note(reasons: Option<&Value>) -> Option<String> {
917944
let actionable = reasons?
918945
.as_array()?
@@ -989,6 +1016,25 @@ fn append_lsp_server_notes(output: &mut String, data: &Value) {
9891016
}
9901017
}
9911018

1019+
fn format_error_lines(errors: &[Value]) -> String {
1020+
errors
1021+
.iter()
1022+
.map(|e| {
1023+
let line = e
1024+
.get("line")
1025+
.and_then(Value::as_u64)
1026+
.map(|n| n.to_string())
1027+
.unwrap_or_else(|| "undefined".to_string());
1028+
let message = e
1029+
.get("message")
1030+
.and_then(Value::as_str)
1031+
.unwrap_or("undefined");
1032+
format!(" Line {line}: {message}")
1033+
})
1034+
.collect::<Vec<_>>()
1035+
.join("\n")
1036+
}
1037+
9921038
// Mirrors packages/aft-bridge/src/edit-summary.ts formatEditSummary.
9931039
fn format_edit_summary(data: &Value) -> String {
9941040
if data.get("rolled_back").and_then(Value::as_bool) == Some(true) {
@@ -2776,3 +2822,53 @@ mod status_memory_tests {
27762822
assert!(!rendered.contains("\"memory\""));
27772823
}
27782824
}
2825+
2826+
#[cfg(test)]
2827+
mod tests {
2828+
use super::*;
2829+
use serde_json::json;
2830+
2831+
fn temp_file(name: &str) -> String {
2832+
let dir = tempfile::tempdir().expect("tempdir");
2833+
dir.path().join(name).to_string_lossy().into_owned()
2834+
}
2835+
2836+
#[test]
2837+
fn format_check_response_with_errors() {
2838+
let file = temp_file("check_test.py");
2839+
let data = json!({
2840+
"file": file,
2841+
"error_count": 2,
2842+
"errors": [
2843+
{ "line": 1, "message": "F401: `os` imported but unused" },
2844+
{ "line": 2, "message": "F401: `sys` imported but unused" }
2845+
]
2846+
});
2847+
let text = format_check_response(&data);
2848+
assert!(text.contains("check_test.py"));
2849+
assert!(text.contains("2 error(s)"));
2850+
assert!(text.contains("Line 1: F401"));
2851+
assert!(text.contains("Line 2: F401"));
2852+
}
2853+
2854+
#[test]
2855+
fn format_check_response_no_errors() {
2856+
let file = temp_file("check_test.py");
2857+
let data = json!({ "file": file, "error_count": 0 });
2858+
let text = format_check_response(&data);
2859+
assert!(text.contains("no errors found"));
2860+
}
2861+
2862+
#[test]
2863+
fn format_check_response_skipped() {
2864+
let file = temp_file("check_test.py");
2865+
let data = json!({
2866+
"file": file,
2867+
"error_count": 0,
2868+
"skipped_reason": "no_checker_configured"
2869+
});
2870+
let text = format_check_response(&data);
2871+
assert!(text.contains("validation skipped"));
2872+
assert!(text.contains("no_checker_configured"));
2873+
}
2874+
}

crates/aft/src/subc_translate.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ pub fn subc_translate_with_context(
195195
"outline" => translate_outline(agent_args, project_root),
196196
"zoom" => translate_zoom(agent_args, project_root),
197197
"inspect" => translate_inspect(agent_args, project_root),
198+
"check" => translate_check(agent_args, project_root),
198199
"callgraph" => translate_callgraph(agent_args, project_root),
199200
"conflicts" => translate_conflicts(agent_args),
200201
"ast_search" => translate_ast_search(agent_args),
@@ -1585,3 +1586,20 @@ fn translate_inspect(args: &Value, project_root: &Path) -> Result<Translated, Tr
15851586
args: out,
15861587
})
15871588
}
1589+
1590+
fn translate_check(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
1591+
let map_in = agent_args_map(args);
1592+
let file_path = map_in
1593+
.get("filePath")
1594+
.and_then(Value::as_str)
1595+
.filter(|s| !s.is_empty())
1596+
.ok_or_else(|| invalid_request("'filePath' is required"))?;
1597+
1598+
let mut out = Map::new();
1599+
insert_resolved_file(&mut out, project_root, file_path);
1600+
1601+
Ok(Translated {
1602+
command: "check".into(),
1603+
args: out,
1604+
})
1605+
}

packages/opencode-plugin/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ export const AftConfigSchema = z.preprocess(
325325
callgraph_chunk_size: z.number().optional(),
326326
/** Codebase health inspection config. Enabled by default; set inspect.enabled=false to hide aft_inspect. */
327327
inspect: InspectConfigSchema.optional(),
328+
/** On-demand type checker validation. Disabled by default; set check.enabled=true to expose aft_check. */
329+
check: z.object({ enabled: z.boolean().optional() }).optional(),
328330
/** Undo backup config. User-only: project config cannot disable or shrink a user's safety net. */
329331
backup: BackupConfigSchema.optional(),
330332
/**

0 commit comments

Comments
 (0)