diff --git a/.gitignore b/.gitignore index b0919d8f..5ebe0366 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ _merge_parsed.json .huskies_port .huskies/bot.toml.bak .huskies/build_hash -.huskies/source-map.json # Per-worktree planning file (written by coder agents, must never reach squash commits) PLAN.md diff --git a/crates/source-map-gen/Cargo.toml b/crates/source-map-gen/Cargo.toml index c93a7f6e..a42e6bfe 100644 --- a/crates/source-map-gen/Cargo.toml +++ b/crates/source-map-gen/Cargo.toml @@ -10,6 +10,10 @@ crate-type = ["lib"] name = "source-map-check" path = "src/main.rs" +[[bin]] +name = "source-map-regen" +path = "src/regen_main.rs" + [dependencies] serde_json = { workspace = true } diff --git a/crates/source-map-gen/src/lib.rs b/crates/source-map-gen/src/lib.rs index bd592cba..6f217cec 100644 --- a/crates/source-map-gen/src/lib.rs +++ b/crates/source-map-gen/src/lib.rs @@ -303,6 +303,67 @@ pub fn update_source_map( Ok(()) } +/// Regenerate the source map from scratch for all tracked source files in `worktree`. +/// +/// Uses `git ls-files` to enumerate every tracked Rust and TypeScript file, extracts +/// their public item signatures, and writes a fresh JSON map sorted by key. Running +/// twice with unchanged source produces byte-identical output (deterministic). +/// +/// Unlike [`update_for_worktree`], this path cannot leave stale entries: every file in +/// the map was present and tracked at the time of writing. +pub fn regenerate_source_map(worktree: &Path, source_map_path: &Path) -> Result<(), String> { + let output = Command::new("git") + .args(["ls-files"]) + .current_dir(worktree) + .output() + .map_err(|e| format!("git ls-files: {e}"))?; + + if !output.status.success() { + return Err(format!( + "git ls-files failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + // Use BTreeMap so keys are sorted alphabetically → deterministic output. + let mut entries: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for rel_path in String::from_utf8_lossy(&output.stdout).lines() { + if rel_path.is_empty() { + continue; + } + let abs_path = worktree.join(rel_path); + if !abs_path.exists() { + continue; + } + let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let items: Vec = match ext { + "rs" => RustAdapter::extract_items(&abs_path) + .into_iter() + .map(serde_json::Value::String) + .collect(), + "ts" | "tsx" => TypeScriptAdapter::extract_items(&abs_path) + .into_iter() + .map(serde_json::Value::String) + .collect(), + _ => continue, + }; + entries.insert(rel_path.to_string(), items); + } + + let map: serde_json::Map = entries + .into_iter() + .map(|(k, v)| (k, serde_json::Value::Array(v))) + .collect(); + + if let Some(parent) = source_map_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?; + } + + write_map(source_map_path, map) +} + /// 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. @@ -311,7 +372,12 @@ pub fn update_source_map( /// /// Errors are returned as `Err(String)`; callers in the spawn flow treat them as /// non-blocking warnings. -pub fn update_for_worktree( +/// +/// # Note +/// This incremental path is retained for testing only. Production map writes use +/// [`regenerate_source_map`] which cannot leave stale entries. +#[cfg(test)] +pub(crate) fn update_for_worktree( worktree_path: &Path, base_branch: &str, source_map_path: &Path, @@ -894,6 +960,59 @@ mod tests { ); } + /// AC4: running `regenerate_source_map` twice on the same source tree produces + /// byte-identical output. + #[test] + fn regenerate_source_map_is_deterministic() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + // Add a few tracked files and commit them. + write_rs( + tmp.path(), + "alpha.rs", + "//! Alpha module.\n\n/// Does alpha.\npub fn alpha() {}\n", + ); + write_rs( + tmp.path(), + "beta.rs", + "//! Beta module.\n\n/// Does beta.\npub fn beta() {}\n", + ); + Command::new("git") + .args(["add", "alpha.rs", "beta.rs"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add files"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let map_path = tmp.path().join("source-map.json"); + + let result1 = regenerate_source_map(tmp.path(), &map_path); + assert!( + result1.is_ok(), + "first regenerate failed: {:?}", + result1.err() + ); + let first = std::fs::read_to_string(&map_path).unwrap(); + + let result2 = regenerate_source_map(tmp.path(), &map_path); + assert!( + result2.is_ok(), + "second regenerate failed: {:?}", + result2.err() + ); + let second = std::fs::read_to_string(&map_path).unwrap(); + + assert_eq!( + first, second, + "regenerate_source_map must be byte-identical on repeated runs" + ); + } + /// `relative_key` strips the root prefix from an absolute path. #[test] fn relative_key_strips_root_prefix() { diff --git a/script/check b/script/check index b9fd69cd..a0eefb24 100755 --- a/script/check +++ b/script/check @@ -13,5 +13,9 @@ cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check echo "=== Running cargo clippy ===" cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --workspace --all-targets -- -D warnings +echo "=== Regenerating source map ===" +cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-regen --quiet -- --project-root "$PROJECT_ROOT" +git -C "$PROJECT_ROOT" add .huskies/source-map.json + echo "=== Checking doc coverage on changed files ===" cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index 74c1d0ad..01a2aa5a 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -223,23 +223,6 @@ pub(super) async fn run_agent_spawn( slog_error!("[agents] pre-commit hook install failed for {sid}: {e}"); } - // 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(); {