Add git-aware SX tools: changed, diff_branch, blame, doc_gen, playwright

- sx_changed: list .sx files changed since a ref with structural summaries
- sx_diff_branch: structural diff of all .sx changes vs base ref
- sx_blame: git blame for .sx files, optionally focused on a tree path
- sx_doc_gen: generate component docs from defcomp/defisland signatures
- sx_playwright: run Playwright browser tests with structured results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 23:25:00 +00:00
parent 76ce0c3ecb
commit 7047a5d7f3
2 changed files with 179 additions and 1 deletions

View File

@@ -502,6 +502,171 @@ let rec handle_tool name args =
Out_channel.with_open_text file (fun oc -> output_string oc source);
text_result (Printf.sprintf "OK — reformatted %s (%d bytes, %d forms)" file (String.length source) (List.length exprs))
| "sx_changed" ->
let base_ref = args |> member "ref" |> to_string_option |> Option.value ~default:"main" in
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
Filename.dirname spec_dir
in
let cmd = Printf.sprintf "cd %s && git diff --name-only %s -- '*.sx' '*.sxc' 2>/dev/null" project_dir base_ref in
let ic = Unix.open_process_in cmd in
let files = ref [] in
(try while true do files := input_line ic :: !files done with End_of_file -> ());
ignore (Unix.close_process_in ic);
let changed = List.rev !files in
if changed = [] then text_result (Printf.sprintf "No .sx files changed since %s" base_ref)
else begin
let lines = List.map (fun rel ->
let full = Filename.concat project_dir rel in
try
let tree = parse_file full in
let summary = value_to_string (call_sx "summarise" [tree; Number 1.0]) in
Printf.sprintf "=== %s ===\n%s" rel summary
with _ -> Printf.sprintf "=== %s === (parse error or deleted)" rel
) changed in
text_result (String.concat "\n\n" lines)
end
| "sx_diff_branch" ->
let base_ref = args |> member "ref" |> to_string_option |> Option.value ~default:"main" in
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
Filename.dirname spec_dir
in
let cmd = Printf.sprintf "cd %s && git diff --name-only %s -- '*.sx' '*.sxc' 2>/dev/null" project_dir base_ref in
let ic = Unix.open_process_in cmd in
let files = ref [] in
(try while true do files := input_line ic :: !files done with End_of_file -> ());
ignore (Unix.close_process_in ic);
let changed = List.rev !files in
if changed = [] then text_result (Printf.sprintf "No .sx files changed since %s" base_ref)
else begin
let lines = List.filter_map (fun rel ->
let full = Filename.concat project_dir rel in
(* Get the base version via git show *)
let base_cmd = Printf.sprintf "cd %s && git show %s:%s 2>/dev/null" project_dir base_ref rel in
let ic2 = Unix.open_process_in base_cmd in
let base_lines = ref [] in
(try while true do base_lines := input_line ic2 :: !base_lines done with End_of_file -> ());
ignore (Unix.close_process_in ic2);
let base_src = String.concat "\n" (List.rev !base_lines) in
try
let tree_b = parse_file full in
if base_src = "" then
Some (Printf.sprintf "=== %s (new file) ===\n%s" rel
(value_to_string (call_sx "summarise" [tree_b; Number 1.0])))
else begin
let tree_a = List (Sx_parser.parse_all base_src) in
let diff = value_to_string (call_sx "tree-diff" [tree_a; tree_b]) in
if diff = "No differences" then None
else Some (Printf.sprintf "=== %s ===\n%s" rel diff)
end
with _ -> Some (Printf.sprintf "=== %s === (parse error)" rel)
) changed in
if lines = [] then text_result "All changed .sx files are structurally identical to base"
else text_result (String.concat "\n\n" lines)
end
| "sx_blame" ->
let file = args |> member "file" |> to_string in
let path_str_arg = args |> member "path" |> to_string_option in
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
Filename.dirname spec_dir
in
(* Get the node's source span by parsing and finding line numbers *)
let tree = parse_file file in
let target_src = match path_str_arg with
| Some ps ->
let path = resolve_path tree ps in
let node = call_sx "navigate" [tree; path] in
if is_nil node then None
else Some (Sx_types.inspect node)
| None -> None
in
let rel_file = relative_path ~base:project_dir file in
let cmd = match target_src with
| Some src ->
(* Find the line range containing this source fragment *)
let first_line = String.sub src 0 (min 40 (String.length src)) in
let escaped = String.concat "" (List.of_seq (Seq.map (fun c ->
if c = '(' || c = ')' || c = '[' || c = ']' || c = '.' || c = '*' || c = '+' || c = '?' || c = '{' || c = '}' || c = '\\' || c = '|' || c = '^' || c = '$'
then Printf.sprintf "\\%c" c else String.make 1 c
) (String.to_seq first_line))) in
Printf.sprintf "cd %s && git blame -L '/%s/,+10' -- %s 2>/dev/null || git blame -- %s 2>/dev/null | head -20" project_dir escaped rel_file rel_file
| None ->
Printf.sprintf "cd %s && git blame -- %s 2>/dev/null | head -30" project_dir rel_file
in
let ic = Unix.open_process_in cmd in
let lines = ref [] in
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
ignore (Unix.close_process_in ic);
text_result (String.concat "\n" (List.rev !lines))
| "sx_doc_gen" ->
let dir = args |> member "dir" |> to_string in
let files = glob_sx_files dir in
let all_docs = List.concat_map (fun path ->
let rel = relative_path ~base:dir path in
try
let exprs = Sx_parser.parse_all (In_channel.with_open_text path In_channel.input_all) in
List.filter_map (fun expr ->
match expr with
| List (Symbol head :: Symbol name :: rest) | ListRef { contents = Symbol head :: Symbol name :: rest } ->
(match head with
| "defcomp" | "defisland" ->
let params_str = match rest with
| List ps :: _ | ListRef { contents = ps } :: _ ->
let keys = List.filter_map (fun p -> match p with
| Symbol s when s <> "&key" && s <> "&rest" && not (String.length s > 0 && s.[0] = '&') -> Some s
| List (Symbol s :: _) when s <> "&key" && s <> "&rest" -> Some (Printf.sprintf "%s (typed)" s)
| _ -> None) ps
in
let has_rest = List.exists (fun p -> match p with Symbol "&rest" -> true | _ -> false) ps in
let key_str = if keys = [] then "" else " Keys: " ^ String.concat ", " keys ^ "\n" in
let rest_str = if has_rest then " Children: yes\n" else "" in
key_str ^ rest_str
| _ -> ""
in
Some (Printf.sprintf "## %s `%s`\nDefined in: %s\nType: %s\n%s" head name rel head params_str)
| "defmacro" ->
Some (Printf.sprintf "## %s `%s`\nDefined in: %s\nType: macro\n" head name rel)
| _ -> None)
| _ -> None
) exprs
with _ -> []
) files in
if all_docs = [] then text_result "(no components found)"
else text_result (String.concat "\n" all_docs)
| "sx_playwright" ->
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
Filename.dirname spec_dir
in
let spec = args |> member "spec" |> to_string_option in
let spec_arg = match spec with Some s -> " " ^ s | None -> "" in
let cmd = Printf.sprintf "cd %s/tests/playwright && npx playwright test%s --reporter=line 2>&1" project_dir spec_arg in
let ic = Unix.open_process_in cmd in
let lines = ref [] in
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
ignore (Unix.close_process_in ic);
let all_lines = List.rev !lines in
let fails = List.filter (fun l -> let t = String.trim l in
String.length t > 1 && (t.[0] = '\xE2' (**) || (String.length t > 4 && String.sub t 0 4 = "FAIL"))) all_lines in
let summary = List.find_opt (fun l ->
try let _ = Str.search_forward (Str.regexp "passed\\|failed") l 0 in true
with Not_found -> false) (List.rev all_lines) in
let result = match summary with
| Some s ->
if fails = [] then s
else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails
| None ->
let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 10) all_lines in
String.concat "\n" last_n
in
text_result result
| "sx_write_file" ->
let file = args |> member "file" |> to_string in
let source = args |> member "source" |> to_string in
@@ -789,6 +954,19 @@ let tool_definitions = `List [
("new_name", `Assoc [("type", `String "string"); ("description", `String "New symbol name")]);
("dry_run", `Assoc [("type", `String "boolean"); ("description", `String "Preview changes without writing (default: false)")])]
["dir"; "old_name"; "new_name"];
tool "sx_changed" "List .sx files changed since a git ref (default: main) with depth-1 summaries."
[("ref", `Assoc [("type", `String "string"); ("description", `String "Git ref to diff against (default: main)")])]
[];
tool "sx_diff_branch" "Structural diff of all .sx changes on current branch vs a base ref. Shows ADDED/REMOVED/CHANGED per file."
[("ref", `Assoc [("type", `String "string"); ("description", `String "Base ref (default: main)")])]
[];
tool "sx_blame" "Git blame for an .sx file, optionally focused on a tree path."
[file_prop; path_prop] ["file"];
tool "sx_doc_gen" "Generate component documentation from all defcomp/defisland/defmacro signatures in a directory."
[dir_prop] ["dir"];
tool "sx_playwright" "Run Playwright browser tests for the SX docs site. Optionally specify a single spec file."
[("spec", `Assoc [("type", `String "string"); ("description", `String "Optional spec file name (e.g. demo-interactions.spec.js)")])]
[];
]
(* ------------------------------------------------------------------ *)

File diff suppressed because one or more lines are too long