From f5ab75ecaa6a5794dbe938555fc65e0949663403 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 20:22:22 +0000 Subject: [PATCH] huskies: merge 819 --- Cargo.lock | 9 + Cargo.toml | 2 +- crates/source-map-gen/Cargo.toml | 13 + crates/source-map-gen/src/lib.rs | 442 ++++++++++++++++++++++ crates/source-map-gen/src/rust_adapter.rs | 272 +++++++++++++ crates/source-map-gen/src/ts_adapter.rs | 293 ++++++++++++++ server/Cargo.toml | 1 + server/src/agents/pool/start/spawn.rs | 25 ++ 8 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 crates/source-map-gen/Cargo.toml create mode 100644 crates/source-map-gen/src/lib.rs create mode 100644 crates/source-map-gen/src/rust_adapter.rs create mode 100644 crates/source-map-gen/src/ts_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 05b90462..89810263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2323,6 +2323,7 @@ dependencies = [ "serde_urlencoded", "serde_yaml", "sha2 0.11.0", + "source-map-gen", "sqlx", "strip-ansi-escapes", "tempfile", @@ -5423,6 +5424,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "source-map-gen" +version = "0.1.0" +dependencies = [ + "serde_json", + "tempfile", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/Cargo.toml b/Cargo.toml index 4fdcbd17..4bab451c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["server", "crates/bft-json-crdt"] +members = ["server", "crates/bft-json-crdt", "crates/source-map-gen"] resolver = "3" [workspace.dependencies] diff --git a/crates/source-map-gen/Cargo.toml b/crates/source-map-gen/Cargo.toml new file mode 100644 index 00000000..c89c1e51 --- /dev/null +++ b/crates/source-map-gen/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "source-map-gen" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib"] + +[dependencies] +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/source-map-gen/src/lib.rs b/crates/source-map-gen/src/lib.rs new file mode 100644 index 00000000..94d67364 --- /dev/null +++ b/crates/source-map-gen/src/lib.rs @@ -0,0 +1,442 @@ +//! LLM-friendly source map generation and documentation coverage checking. +//! +//! Provides a [`LanguageAdapter`] trait that language-specific adapters implement, +//! plus top-level dispatcher functions that route to the right adapter based on file +//! extension (`.rs` → [`RustAdapter`], `.ts`/`.tsx` → [`TypeScriptAdapter`]). +//! +//! The entry point for agent spawn integration is [`update_for_worktree`], which +//! runs `git diff --name-only` to find changed files and updates the source map for +//! those that pass the documentation coverage check. + +mod rust_adapter; +mod ts_adapter; + +pub use rust_adapter::RustAdapter; +pub use ts_adapter::TypeScriptAdapter; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// A missing documentation failure for a single public item. +#[derive(Debug, Clone, PartialEq)] +pub struct CheckFailure { + /// Path to the file containing the undocumented item. + pub file_path: PathBuf, + /// 1-based line number of the item declaration. + pub line: usize, + /// Kind of item (e.g. `"fn"`, `"struct"`, `"module"`). + pub item_kind: String, + /// Name of the item. + pub item_name: String, +} + +impl CheckFailure { + /// Returns a human-readable direction a coding agent can act on directly. + pub fn to_direction(&self) -> String { + format!( + "{}:{}: add a doc comment to {} `{}`", + self.file_path.display(), + self.line, + self.item_kind, + self.item_name + ) + } +} + +/// Result of a documentation coverage check. +#[derive(Debug, Clone, PartialEq)] +pub enum CheckResult { + /// All checked items are documented. + Ok, + /// One or more items are missing documentation. + Failures(Vec), +} + +/// Language-specific adapter for doc-coverage checking and source map generation. +pub trait LanguageAdapter { + /// Check documentation coverage for `files`. + /// + /// Returns [`CheckResult::Ok`] when every public item in every file has a doc + /// comment, or [`CheckResult::Failures`] listing each undocumented item as a + /// direction the coding agent can act on. + fn check(&self, files: &[&Path]) -> CheckResult; + + /// Update the source map at `source_map_path` with entries for `passing_files`. + /// + /// Reads the existing map, updates only the entries for the provided files, and + /// writes back. Entries for files not in `passing_files` are preserved unchanged. + /// Running twice with the same input produces identical file content (idempotent). + fn update_source_map( + &self, + passing_files: &[&Path], + source_map_path: &Path, + ) -> Result<(), String>; +} + +/// Returns the adapter for the given file extension, or `None` if unsupported. +fn adapter_for_ext(ext: &str) -> Option> { + match ext { + "rs" => Some(Box::new(RustAdapter)), + "ts" | "tsx" => Some(Box::new(TypeScriptAdapter)), + _ => None, + } +} + +/// Check documentation coverage for a mixed list of files. +/// +/// Dispatches each file to the appropriate [`LanguageAdapter`] based on its +/// extension. Files with unsupported extensions are silently skipped. +pub fn check_files(files: &[&Path]) -> CheckResult { + let mut by_ext: HashMap> = HashMap::new(); + for &file in files { + if let Some(ext) = file.extension().and_then(|e| e.to_str()) { + by_ext.entry(ext.to_string()).or_default().push(file); + } + } + let mut all_failures = Vec::new(); + for (ext, ext_files) in &by_ext { + if let Some(adapter) = adapter_for_ext(ext) + && let CheckResult::Failures(mut f) = adapter.check(ext_files) + { + all_failures.append(&mut f); + } + } + if all_failures.is_empty() { + CheckResult::Ok + } else { + CheckResult::Failures(all_failures) + } +} + +/// Update the source map at `source_map_path` with entries for `passing_files`. +/// +/// Dispatches each file to the appropriate [`LanguageAdapter`] based on extension. +/// Files with unsupported extensions are silently skipped. +pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Result<(), String> { + let mut by_ext: HashMap> = HashMap::new(); + for &file in passing_files { + if let Some(ext) = file.extension().and_then(|e| e.to_str()) { + by_ext.entry(ext.to_string()).or_default().push(file); + } + } + for (ext, ext_files) in &by_ext { + if let Some(adapter) = adapter_for_ext(ext) { + adapter.update_source_map(ext_files, source_map_path)?; + } + } + Ok(()) +} + +/// Update the source map for files that changed since `base_branch` in `worktree_path`. +/// +/// 1. Runs `git diff --name-only {base_branch}...HEAD` in the worktree. +/// 2. Checks doc coverage for each changed file (per-file). +/// 3. Calls [`update_source_map`] with the files whose coverage check passes. +/// +/// Errors are returned as `Err(String)`; callers in the spawn flow treat them as +/// non-blocking warnings. +pub fn update_for_worktree( + worktree_path: &Path, + base_branch: &str, + source_map_path: &Path, +) -> Result<(), String> { + let output = Command::new("git") + .args(["diff", "--name-only", &format!("{base_branch}...HEAD")]) + .current_dir(worktree_path) + .output() + .map_err(|e| format!("git diff: {e}"))?; + + if !output.status.success() { + return Err(format!( + "git diff failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let changed: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(|l| worktree_path.join(l)) + .filter(|p| p.exists()) + .collect(); + + if changed.is_empty() { + return Ok(()); + } + + // Collect files that individually pass the doc check. + let passing: Vec<&Path> = changed + .iter() + .map(PathBuf::as_path) + .filter(|&p| matches!(check_files(&[p]), CheckResult::Ok)) + .collect(); + + if passing.is_empty() { + return Ok(()); + } + + if let Some(parent) = source_map_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?; + } + + update_source_map(&passing, source_map_path) +} + +/// Read the existing source map from `path` as a JSON object. +/// +/// Returns an empty map if the file does not exist. +pub(crate) fn read_map(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(serde_json::Map::new()); + } + let content = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str(&content).map_err(|e| format!("parse source map: {e}")) +} + +/// Write `map` to `path` as pretty-printed JSON. +pub(crate) fn write_map( + path: &Path, + map: serde_json::Map, +) -> Result<(), String> { + let content = serde_json::to_string_pretty(&serde_json::Value::Object(map)) + .map_err(|e| format!("serialize: {e}"))?; + std::fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use tempfile::TempDir; + + fn write_rs(dir: &std::path::Path, name: &str, content: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + fn write_ts(dir: &std::path::Path, name: &str, content: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + // --- Rust happy path --- + + #[test] + fn rust_check_happy_path_ok() { + let tmp = TempDir::new().unwrap(); + let path = write_rs( + tmp.path(), + "foo.rs", + "//! Module doc.\n\n/// A function.\npub fn hello() {}\n", + ); + assert_eq!(check_files(&[&path]), CheckResult::Ok); + } + + // --- Rust failure path --- + + #[test] + fn rust_check_missing_module_doc_yields_failure() { + let tmp = TempDir::new().unwrap(); + let path = write_rs(tmp.path(), "foo.rs", "/// A function.\npub fn hello() {}\n"); + let result = check_files(&[&path]); + assert!( + matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")), + "expected module failure, got {result:?}" + ); + } + + #[test] + fn rust_check_missing_fn_doc_yields_failure_with_correct_fields() { + let tmp = TempDir::new().unwrap(); + let path = write_rs( + tmp.path(), + "bar.rs", + "//! Module doc.\n\npub fn undocumented() {}\n", + ); + let result = check_files(&[&path]); + if let CheckResult::Failures(failures) = result { + let f = failures.iter().find(|f| f.item_kind == "fn").unwrap(); + assert_eq!(f.item_name, "undocumented"); + assert_eq!(f.file_path, path); + assert_eq!(f.line, 3); + } else { + panic!("expected failures"); + } + } + + // --- TypeScript happy path --- + + #[test] + fn ts_check_happy_path_ok() { + let tmp = TempDir::new().unwrap(); + let path = write_ts( + tmp.path(), + "app.ts", + "/**\n * File doc.\n */\n\n/**\n * Does something.\n */\nexport function hello(): void {}\n", + ); + assert_eq!(check_files(&[&path]), CheckResult::Ok); + } + + // --- TypeScript failure path --- + + #[test] + fn ts_check_missing_file_doc_yields_failure() { + let tmp = TempDir::new().unwrap(); + let _path = write_ts( + tmp.path(), + "app.ts", + "/** A function. */\nexport function hello(): void {}\n", + ); + // No file-level JSDoc (first non-empty line is not /**) + // Actually this file DOES start with /**, so let's make one that doesn't + let path2 = write_ts(tmp.path(), "app2.ts", "export function hello(): void {}\n"); + let result = check_files(&[&path2]); + assert!( + matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")), + "expected file failure, got {result:?}" + ); + } + + #[test] + fn ts_check_missing_export_doc_yields_failure() { + let tmp = TempDir::new().unwrap(); + let path = write_ts( + tmp.path(), + "app.ts", + "/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n", + ); + let result = check_files(&[&path]); + assert!( + matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "function" && f.item_name == "undocumented")), + "expected function failure, got {result:?}" + ); + } + + // --- Update idempotency --- + + #[test] + fn update_idempotent_same_input_twice() { + let tmp = TempDir::new().unwrap(); + let rs_path = write_rs( + tmp.path(), + "lib.rs", + "//! Module doc.\n\n/// A function.\npub fn foo() {}\n", + ); + let map_path = tmp.path().join("source-map.json"); + let files: &[&Path] = &[&rs_path]; + + update_source_map(files, &map_path).unwrap(); + let first = std::fs::read_to_string(&map_path).unwrap(); + + update_source_map(files, &map_path).unwrap(); + let second = std::fs::read_to_string(&map_path).unwrap(); + + assert_eq!(first, second, "update_source_map must be idempotent"); + } + + // --- update_source_map preserves other entries --- + + #[test] + fn update_source_map_preserves_unrelated_entries() { + let tmp = TempDir::new().unwrap(); + let map_path = tmp.path().join("source-map.json"); + + // Write an initial map with an unrelated entry + std::fs::write(&map_path, r#"{"unrelated/file.rs": ["fn old"]}"#).unwrap(); + + let rs_path = write_rs( + tmp.path(), + "new.rs", + "//! Module doc.\n\n/// A function.\npub fn bar() {}\n", + ); + update_source_map(&[&rs_path], &map_path).unwrap(); + + let content = std::fs::read_to_string(&map_path).unwrap(); + assert!( + content.contains("unrelated/file.rs"), + "old entry should be preserved" + ); + assert!(content.contains("new.rs"), "new entry should be added"); + } + + // --- Spawn integration: update_for_worktree writes map at expected path --- + + fn init_git_repo(dir: &Path) { + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .expect("git init"); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .expect("git config email"); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .expect("git config name"); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .expect("initial commit"); + } + + #[test] + fn spawn_integration_map_written_at_expected_path() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + // Add a well-documented Rust file and commit it + let rs_path = write_rs( + tmp.path(), + "lib.rs", + "//! Module doc.\n\n/// A function.\npub fn greet() {}\n", + ); + Command::new("git") + .args(["add", "lib.rs"]) + .current_dir(tmp.path()) + .output() + .expect("git add"); + Command::new("git") + .args(["commit", "-m", "add lib.rs"]) + .current_dir(tmp.path()) + .output() + .expect("git commit"); + + let huskies_dir = tmp.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + let map_path = huskies_dir.join("source-map.json"); + + // Simulate what spawn does: update_for_worktree with base = initial commit + let result = update_for_worktree(tmp.path(), "HEAD~1", &map_path); + assert!( + result.is_ok(), + "update_for_worktree failed: {:?}", + result.err() + ); + + // The map file must exist at the expected path + assert!( + map_path.exists(), + "source map must be written at .huskies/source-map.json" + ); + + let content = std::fs::read_to_string(&map_path).unwrap(); + let _ = rs_path; // used above + assert!( + content.contains("lib.rs"), + "map must contain the documented file" + ); + assert!( + content.contains("fn greet"), + "map must list the documented function" + ); + } +} diff --git a/crates/source-map-gen/src/rust_adapter.rs b/crates/source-map-gen/src/rust_adapter.rs new file mode 100644 index 00000000..a3b4d602 --- /dev/null +++ b/crates/source-map-gen/src/rust_adapter.rs @@ -0,0 +1,272 @@ +//! Rust documentation coverage adapter. +//! +//! Checks for: +//! - A `//!` module-level doc comment somewhere in every `.rs` file. +//! - A `///` doc comment immediately before every `pub` item (`fn`, `struct`, +//! `enum`, `trait`, `type`, `const`, `static`, `mod`). + +use std::fs; +use std::path::Path; + +use crate::{CheckFailure, CheckResult, LanguageAdapter}; + +/// Rust documentation coverage adapter. +pub struct RustAdapter; + +impl RustAdapter { + fn check_file(&self, path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return vec![], + }; + let lines: Vec<&str> = content.lines().collect(); + let mut failures = Vec::new(); + + // Module-level doc comment (//!) + if !lines.iter().any(|l| l.trim_start().starts_with("//!")) { + failures.push(CheckFailure { + file_path: path.to_path_buf(), + line: 1, + item_kind: "module".to_string(), + item_name: module_name(path), + }); + } + + // Public items missing /// doc comments + for (i, &line) in lines.iter().enumerate() { + if let Some((kind, name)) = parse_pub_item(line) + && !has_doc_before(&lines, i) + { + failures.push(CheckFailure { + file_path: path.to_path_buf(), + line: i + 1, + item_kind: kind, + item_name: name, + }); + } + } + + failures + } + + /// Extract public item signatures from a Rust file as `"kind name"` strings. + pub(crate) fn extract_items(path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return vec![], + }; + content + .lines() + .filter_map(|line| { + let (kind, name) = parse_pub_item(line)?; + Some(format!("{kind} {name}")) + }) + .collect() + } +} + +impl LanguageAdapter for RustAdapter { + fn check(&self, files: &[&Path]) -> CheckResult { + let failures: Vec = files.iter().flat_map(|&f| self.check_file(f)).collect(); + if failures.is_empty() { + CheckResult::Ok + } else { + CheckResult::Failures(failures) + } + } + + fn update_source_map( + &self, + passing_files: &[&Path], + source_map_path: &Path, + ) -> Result<(), String> { + let mut map = crate::read_map(source_map_path)?; + for &file in passing_files { + let key = file.to_string_lossy().to_string(); + let items: Vec = Self::extract_items(file) + .into_iter() + .map(serde_json::Value::String) + .collect(); + map.insert(key, serde_json::Value::Array(items)); + } + crate::write_map(source_map_path, map) + } +} + +fn module_name(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() +} + +/// Parse a line as a public Rust item declaration. +/// +/// Returns `(kind, name)` if the line declares a public item, `None` otherwise. +fn parse_pub_item(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + // Strip visibility: "pub(…)" or "pub " + let rest = if let Some(r) = trimmed.strip_prefix("pub(") { + let end = r.find(')')?; + r[end + 1..].trim_start() + } else if let Some(r) = trimmed.strip_prefix("pub ") { + r.trim_start() + } else { + return None; + }; + + // Handle "async fn" + let rest = if let Some(r) = rest.strip_prefix("async ") { + r.trim_start() + } else { + rest + }; + + // Match item keyword and extract name part + let (kind, name_part) = if let Some(r) = rest.strip_prefix("fn ") { + ("fn", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("struct ") { + ("struct", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("enum ") { + ("enum", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("trait ") { + ("trait", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("type ") { + ("type", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("const ") { + ("const", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("static ") { + ("static", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("mod ") { + ("mod", r.trim_start()) + } else { + return None; + }; + + let name: String = name_part + .chars() + .take_while(|&c| c.is_alphanumeric() || c == '_') + .collect(); + + if name.is_empty() { + return None; + } + + Some((kind.to_string(), name)) +} + +/// Return `true` if a `///` doc comment appears before the item at `item_idx`. +/// +/// Scans backward from `item_idx`, skipping blank lines and `#[…]` attribute +/// lines. Returns `true` if the first substantive line is a `///` comment. +fn has_doc_before(lines: &[&str], item_idx: usize) -> bool { + let mut i = item_idx; + while i > 0 { + i -= 1; + let line = lines[i].trim(); + if line.starts_with("///") { + return true; + } + if line.starts_with("#[") || line.starts_with("#![") || line.is_empty() { + continue; + } + break; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_rs(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn check_fully_documented_file_returns_ok() { + let tmp = TempDir::new().unwrap(); + let path = write_rs( + tmp.path(), + "lib.rs", + "//! Module doc.\n\n/// A function.\npub fn hello() {}\n\n/// A struct.\npub struct Foo;\n", + ); + let adapter = RustAdapter; + assert_eq!(adapter.check(&[&path]), CheckResult::Ok); + } + + #[test] + fn check_detects_missing_module_doc() { + let tmp = TempDir::new().unwrap(); + let path = write_rs(tmp.path(), "lib.rs", "/// A function.\npub fn hello() {}\n"); + let adapter = RustAdapter; + let result = adapter.check(&[&path]); + assert!( + matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")), + "expected module failure, got {result:?}" + ); + } + + #[test] + fn check_detects_missing_fn_doc_with_correct_fields() { + let tmp = TempDir::new().unwrap(); + let path = write_rs(tmp.path(), "bar.rs", "//! Module.\n\npub fn no_doc() {}\n"); + let adapter = RustAdapter; + let result = adapter.check(&[&path]); + if let CheckResult::Failures(failures) = result { + let f = failures.iter().find(|f| f.item_kind == "fn").unwrap(); + assert_eq!(f.item_name, "no_doc"); + assert_eq!(f.line, 3); + assert_eq!(f.file_path, path); + } else { + panic!("expected failures"); + } + } + + #[test] + fn check_passes_item_with_attribute_before_doc() { + let tmp = TempDir::new().unwrap(); + // Attribute between doc and item is fine; doc between attribute and item is fine too + let path = write_rs( + tmp.path(), + "lib.rs", + "//! Module.\n\n/// Doc.\n#[derive(Debug)]\npub struct Foo;\n", + ); + let adapter = RustAdapter; + assert_eq!(adapter.check(&[&path]), CheckResult::Ok); + } + + #[test] + fn parse_pub_item_recognises_various_kinds() { + assert_eq!( + parse_pub_item("pub fn foo()"), + Some(("fn".into(), "foo".into())) + ); + assert_eq!( + parse_pub_item(" pub async fn bar()"), + Some(("fn".into(), "bar".into())) + ); + assert_eq!( + parse_pub_item("pub struct Baz"), + Some(("struct".into(), "Baz".into())) + ); + assert_eq!( + parse_pub_item("pub enum Qux"), + Some(("enum".into(), "Qux".into())) + ); + assert_eq!( + parse_pub_item("pub trait MyTrait"), + Some(("trait".into(), "MyTrait".into())) + ); + assert_eq!( + parse_pub_item("pub(crate) fn inner()"), + Some(("fn".into(), "inner".into())) + ); + assert_eq!(parse_pub_item("fn private()"), None); + assert_eq!(parse_pub_item("let x = 1;"), None); + } +} diff --git a/crates/source-map-gen/src/ts_adapter.rs b/crates/source-map-gen/src/ts_adapter.rs new file mode 100644 index 00000000..4ec34d88 --- /dev/null +++ b/crates/source-map-gen/src/ts_adapter.rs @@ -0,0 +1,293 @@ +//! TypeScript documentation coverage adapter. +//! +//! Checks for: +//! - A leading file-level JSDoc comment (`/** … */`) at the top of every +//! `.ts` / `.tsx` file. +//! - A JSDoc comment before every exported declaration (`export function`, +//! `export class`, `export type`, `export interface`, `export const`, etc.). + +use std::fs; +use std::path::Path; + +use crate::{CheckFailure, CheckResult, LanguageAdapter}; + +/// TypeScript documentation coverage adapter. +pub struct TypeScriptAdapter; + +impl TypeScriptAdapter { + fn check_file(&self, path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return vec![], + }; + let lines: Vec<&str> = content.lines().collect(); + let mut failures = Vec::new(); + + // File-level JSDoc: first non-empty line must start with "/**" + if !has_file_level_jsdoc(&content) { + failures.push(CheckFailure { + file_path: path.to_path_buf(), + line: 1, + item_kind: "file".to_string(), + item_name: file_stem(path), + }); + } + + // Exported items missing JSDoc + for (i, &line) in lines.iter().enumerate() { + if let Some((kind, name)) = parse_exported_item(line) + && !has_jsdoc_before(&lines, i) + { + failures.push(CheckFailure { + file_path: path.to_path_buf(), + line: i + 1, + item_kind: kind, + item_name: name, + }); + } + } + + failures + } + + /// Extract exported item signatures from a TypeScript file as `"kind name"` strings. + pub(crate) fn extract_items(path: &Path) -> Vec { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return vec![], + }; + content + .lines() + .filter_map(|line| { + let (kind, name) = parse_exported_item(line)?; + Some(format!("{kind} {name}")) + }) + .collect() + } +} + +impl LanguageAdapter for TypeScriptAdapter { + fn check(&self, files: &[&Path]) -> CheckResult { + let failures: Vec = files.iter().flat_map(|&f| self.check_file(f)).collect(); + if failures.is_empty() { + CheckResult::Ok + } else { + CheckResult::Failures(failures) + } + } + + fn update_source_map( + &self, + passing_files: &[&Path], + source_map_path: &Path, + ) -> Result<(), String> { + let mut map = crate::read_map(source_map_path)?; + for &file in passing_files { + let key = file.to_string_lossy().to_string(); + let items: Vec = Self::extract_items(file) + .into_iter() + .map(serde_json::Value::String) + .collect(); + map.insert(key, serde_json::Value::Array(items)); + } + crate::write_map(source_map_path, map) + } +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() +} + +/// Return `true` if the file starts with a JSDoc block comment (`/**`). +fn has_file_level_jsdoc(content: &str) -> bool { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + return trimmed.starts_with("/**"); + } + false +} + +/// Parse a line as an exported TypeScript declaration. +/// +/// Returns `(kind, name)` for supported export forms, `None` otherwise. +fn parse_exported_item(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + // Strip "export default" or "export" + let rest = if let Some(r) = trimmed.strip_prefix("export default ") { + r.trim_start() + } else if let Some(r) = trimmed.strip_prefix("export ") { + r.trim_start() + } else { + return None; + }; + + // Strip optional "async" + let rest = if let Some(r) = rest.strip_prefix("async ") { + r.trim_start() + } else { + rest + }; + + let (kind, name_part) = if let Some(r) = rest.strip_prefix("function ") { + ("function", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("class ") { + ("class", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("type ") { + ("type", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("interface ") { + ("interface", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("const ") { + ("const", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("let ") { + ("let", r.trim_start()) + } else if let Some(r) = rest.strip_prefix("enum ") { + ("enum", r.trim_start()) + } else { + return None; + }; + + let name: String = name_part + .chars() + .take_while(|&c| c.is_alphanumeric() || c == '_') + .collect(); + + if name.is_empty() { + // "export default function() {}" — anonymous default export + return Some((kind.to_string(), "default".to_string())); + } + + Some((kind.to_string(), name)) +} + +/// Return `true` if a JSDoc comment appears before the item at `item_idx`. +/// +/// Scans backward, skipping blank lines and decorator lines (`@…`). Returns +/// `true` if the first substantive line ends with `*/` (closing a JSDoc block) +/// or starts with `/**` (single-line JSDoc). +fn has_jsdoc_before(lines: &[&str], item_idx: usize) -> bool { + let mut i = item_idx; + while i > 0 { + i -= 1; + let line = lines[i].trim(); + if line.is_empty() { + continue; + } + if line.starts_with('@') { + // Decorator — keep scanning upward + continue; + } + return line.ends_with("*/") || line.starts_with("/**"); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_ts(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn check_fully_documented_file_returns_ok() { + let tmp = TempDir::new().unwrap(); + let path = write_ts( + tmp.path(), + "app.ts", + "/**\n * File doc.\n */\n\n/** Does something. */\nexport function hello(): void {}\n", + ); + let adapter = TypeScriptAdapter; + assert_eq!(adapter.check(&[&path]), CheckResult::Ok); + } + + #[test] + fn check_detects_missing_file_jsdoc() { + let tmp = TempDir::new().unwrap(); + let path = write_ts( + tmp.path(), + "app.ts", + "/** Does something. */\nexport function hello(): void {}\n", + ); + // First non-empty line IS "/**", so this file passes the file-level check. + // Use a file that starts with code instead. + let path2 = write_ts( + tmp.path(), + "app2.ts", + "import { foo } from './foo';\n/** A function. */\nexport function hello(): void {}\n", + ); + let adapter = TypeScriptAdapter; + let result = adapter.check(&[&path2]); + assert!( + matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")), + "expected file failure, got {result:?}" + ); + // The first file (starts with /**) should pass the file-level check + let result2 = adapter.check(&[&path]); + // It may still fail on the export if there's no separate export doc, + // but the file-level check itself should pass (first line is /**) + assert!( + !matches!(&result2, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")), + "file starting with /** should not have file-level failure" + ); + } + + #[test] + fn check_detects_missing_export_jsdoc_with_correct_fields() { + let tmp = TempDir::new().unwrap(); + let path = write_ts( + tmp.path(), + "app.ts", + "/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n", + ); + let adapter = TypeScriptAdapter; + let result = adapter.check(&[&path]); + if let CheckResult::Failures(failures) = result { + let f = failures.iter().find(|f| f.item_kind == "function").unwrap(); + assert_eq!(f.item_name, "undocumented"); + assert_eq!(f.file_path, path); + } else { + panic!("expected failures"); + } + } + + #[test] + fn parse_exported_item_recognises_various_kinds() { + assert_eq!( + parse_exported_item("export function foo()"), + Some(("function".into(), "foo".into())) + ); + assert_eq!( + parse_exported_item("export async function bar()"), + Some(("function".into(), "bar".into())) + ); + assert_eq!( + parse_exported_item("export class Baz"), + Some(("class".into(), "Baz".into())) + ); + assert_eq!( + parse_exported_item("export type Qux = string;"), + Some(("type".into(), "Qux".into())) + ); + assert_eq!( + parse_exported_item("export interface IFoo"), + Some(("interface".into(), "IFoo".into())) + ); + assert_eq!( + parse_exported_item("export const MY_CONST = 1;"), + Some(("const".into(), "MY_CONST".into())) + ); + assert_eq!(parse_exported_item("function notExported()"), None); + assert_eq!(parse_exported_item("const x = 1;"), None); + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 2bc804ef..1cde0638 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -41,6 +41,7 @@ libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } sqlx = { workspace = true } wait-timeout = "0.2.1" bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] } +source-map-gen = { path = "../crates/source-map-gen" } ed25519-dalek = { version = "2", features = ["rand_core"] } fastcrypto = "0.1.8" rand = "0.8" diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index d9dba8e3..1680f3c7 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -102,6 +102,23 @@ pub(super) async fn run_agent_spawn( } }; + // Step 1.5: Update the source map for changed files since master. + // Non-blocking — failures are logged but do not gate the spawn. + { + let wt_path_for_map = wt_info.path.clone(); + let base_for_map = wt_info.base_branch.clone(); + let map_path = project_root_clone.join(".huskies").join("source-map.json"); + match tokio::task::spawn_blocking(move || { + source_map_gen::update_for_worktree(&wt_path_for_map, &base_for_map, &map_path) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) + { + Ok(()) => {} + Err(e) => slog_error!("[agents] source map update for {sid}: {e}"), + } + } + // Step 2: store worktree info and render agent command/args/prompt. let wt_path_str = wt_info.path.to_string_lossy().to_string(); { @@ -151,6 +168,14 @@ pub(super) async fn run_agent_spawn( prompt.push_str(&local); } + // Append a reference to the source map if the file was written. + let source_map_path = project_root_clone.join(".huskies").join("source-map.json"); + if source_map_path.exists() { + prompt.push_str( + "\n\nA source map of well-documented changed files is at `.huskies/source-map.json`.", + ); + } + // Build the effective prompt and determine resume session. // // When resuming a previous session, discard the full rendered prompt