Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/cli-merge-pipeline/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ lib-subprocess = { path = "../lib-subprocess" }

[dev-dependencies]
tempfile = "3"
filetime = "0.2"

# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026)
140 changes: 133 additions & 7 deletions src/cli-merge-pipeline/src/feedback/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,9 @@ pub fn filter_transcripts(
.map_err(|e| format!("出力ファイル作成失敗 {}: {}", out_path.display(), e))?;

let mut written = 0usize;
let entries = fs::read_dir(source_dir)
.map_err(|e| format!("transcript dir 読込失敗 {}: {}", source_dir.display(), e))?;
let jsonl_paths = collect_jsonl_paths_in_deterministic_order(source_dir)?;

for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
for path in jsonl_paths {
let file = match fs::File::open(&path) {
Ok(f) => f,
Err(_) => continue,
Expand All @@ -81,6 +76,37 @@ pub fn filter_transcripts(
Ok(written)
}

/// `source_dir` 内の `*.jsonl` を決定論的な順序で収集する。
///
/// `fs::read_dir` の走査順は OS/filesystem 依存で非決定的なため、
/// [`transcript_ordering_key`] でソートして複数セッション jsonl 間の
/// 処理順を決定論化する (ADR-030 determinism 目標)。
fn collect_jsonl_paths_in_deterministic_order(source_dir: &Path) -> Result<Vec<PathBuf>, String> {
let entries = fs::read_dir(source_dir)
.map_err(|e| format!("transcript dir 読込失敗 {}: {}", source_dir.display(), e))?;

let mut jsonl_paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|s| s.to_str()) == Some("jsonl"))
.collect();

jsonl_paths.sort_by_key(|path| transcript_ordering_key(path));
Ok(jsonl_paths)
}

/// transcript ソート用のキー: `(mtime, path)`。
///
/// 一次キーは mtime。mtime が同値の場合 (粒度の粗い filesystem や metadata 取得失敗で
/// `UNIX_EPOCH` に fallback したケース) でも二次キー `PathBuf` により read_dir の入力順に
/// 依存しない完全な決定論順序を保証する。
fn transcript_ordering_key(path: &Path) -> (std::time::SystemTime, PathBuf) {
let mtime = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
(mtime, path.to_path_buf())
}

/// ISO 8601 UTC タイムスタンプを lexicographic 比較用に正規化する。
///
/// `gh api` は秒精度 (`…:SSZ`) を返し、Claude transcript は ms 精度 (`…:SS.fffZ`) を返す。
Expand Down Expand Up @@ -124,6 +150,33 @@ fn entry_matches_filter(line: &str, range: &PrTimeRange) -> bool {
mod tests {
use super::*;

fn unique_temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"feedback-filter-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
));
fs::create_dir_all(&dir).unwrap();
dir
}

fn write_transcript_line(dir: &Path, name: &str, timestamp: &str, id: &str) -> PathBuf {
let path = dir.join(name);
let line = format!(r#"{{"type":"user","timestamp":"{timestamp}","id":"{id}"}}"#);
fs::write(&path, format!("{line}\n")).unwrap();
path
}

fn range_covering_0900_to_0930() -> PrTimeRange {
PrTimeRange {
first_commit_time: "2026-04-25T08:00:00.000Z".into(),
merged_at: "2026-04-25T10:00:00.000Z".into(),
}
}

#[test]
fn project_id_windows_drive() {
let p = Path::new("E:\\work\\claude-code-hook-test");
Expand Down Expand Up @@ -260,4 +313,77 @@ mod tests {

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn filter_transcripts_orders_by_mtime_not_filename() {
let dir = unique_temp_dir("order");

write_transcript_line(
&dir,
"zzz-session.jsonl",
"2026-04-25T09:00:00.000Z",
"first-written",
);
std::thread::sleep(std::time::Duration::from_millis(20));
write_transcript_line(
&dir,
"aaa-session.jsonl",
"2026-04-25T09:05:00.000Z",
"second-written",
);

let out_path = dir.join("filtered.jsonl");
let written = filter_transcripts(&dir, &range_covering_0900_to_0930(), &out_path).unwrap();
assert_eq!(written, 2);

let out = fs::read_to_string(&out_path).unwrap();
let first_pos = out
.find("first-written")
.expect("first-written 行が存在する");
let second_pos = out
.find("second-written")
.expect("second-written 行が存在する");
assert!(
first_pos < second_pos,
"mtime が古いファイルが filename の alphabetical 順に関わらず先に処理されるべき: {out}"
);

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn filter_transcripts_breaks_mtime_ties_by_path_deterministically() {
let dir = unique_temp_dir("tie");

let zzz_path = write_transcript_line(
&dir,
"zzz-session.jsonl",
"2026-04-25T09:00:00.000Z",
"zzz-line",
);
let aaa_path = write_transcript_line(
&dir,
"aaa-session.jsonl",
"2026-04-25T09:05:00.000Z",
"aaa-line",
);

let shared_mtime = filetime::FileTime::from_unix_time(1_745_571_600, 0);
filetime::set_file_mtime(&zzz_path, shared_mtime).unwrap();
filetime::set_file_mtime(&aaa_path, shared_mtime).unwrap();

let out_path = dir.join("filtered.jsonl");
let written = filter_transcripts(&dir, &range_covering_0900_to_0930(), &out_path).unwrap();
assert_eq!(written, 2);

let out = fs::read_to_string(&out_path).unwrap();
let aaa_pos = out.find("aaa-line").expect("aaa-line 行が存在する");
let zzz_pos = out.find("zzz-line").expect("zzz-line 行が存在する");
assert!(
aaa_pos < zzz_pos,
"mtime 同値のとき二次キー PathBuf の昇順 (aaa < zzz) で決定論的に処理されるべき: {out}"
);

let _ = fs::remove_dir_all(&dir);
}
}
Loading