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
22 changes: 22 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ cd ../..
- **Calls** — 呼び出しクラスタと関数のつながりを追跡
4. ファイルを保存すると自動的に再解析される

## 検証方法

FlowMap には、実際の Git コミットペアをリプレイし、graph diff の検出方向が期待どおりかを確認する自動検証が含まれています。

- コミットリプレイ runner: `scripts/run_commit_replay.mjs`
- シナリオ runner(合成回帰セット): `scripts/run_sample_scenarios.mjs`
- リプレイ要約 builder: `scripts/build_replay_summary.mjs`

例:

```bash
node scripts/run_commit_replay.mjs \
--repo /path/to/swift-repo \
--count 100 \
--report reports/replay-100.json
```

現在の統合検証アウトプット:

- `reports/replay-validation-bundle.md`
- `reports/replay-validation-bundle.json`

## ライセンス

FlowMap は source-available モデルで提供されています。
Expand Down
22 changes: 22 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ cd ../..
- **Calls** — 호출 군집과 함수 연결 흐름 추적
4. 파일을 저장하면 자동으로 재분석됩니다

## 검증 방식

FlowMap은 실제 Git 커밋 쌍을 리플레이해서 diff 검출 방향이 맞는지 확인하는 자동 검증을 제공합니다.

- 커밋 리플레이 러너: `scripts/run_commit_replay.mjs`
- 시나리오 러너(합성 회귀 세트): `scripts/run_sample_scenarios.mjs`
- 리플레이 요약 빌더: `scripts/build_replay_summary.mjs`

예시:

```bash
node scripts/run_commit_replay.mjs \
--repo /path/to/swift-repo \
--count 100 \
--report reports/replay-100.json
```

현재 통합 검증 산출물:

- `reports/replay-validation-bundle.md`
- `reports/replay-validation-bundle.json`

## 라이선스

FlowMap은 source-available 방식으로 제공됩니다.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ cd ../..
- **Calls** — trace call clusters and follow how functions connect
4. Save a file to trigger automatic re-analysis

## Validation

FlowMap includes automated replay validation that replays real Git commit pairs and checks whether graph diff changes are detected in the expected direction.

- Commit replay runner: `scripts/run_commit_replay.mjs`
- Scenario runner (synthetic regression set): `scripts/run_sample_scenarios.mjs`
- Replay summary builder: `scripts/build_replay_summary.mjs`

Example:

```bash
node scripts/run_commit_replay.mjs \
--repo /path/to/swift-repo \
--count 100 \
--report reports/replay-100.json
```

Current bundled validation output:

- `reports/replay-validation-bundle.md`
- `reports/replay-validation-bundle.json`

## License

FlowMap is source-available.
Expand Down
123 changes: 104 additions & 19 deletions crates/engine/src/git_diff.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,65 @@
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

/// Run `git diff --name-only HEAD` in `workspace_root` and return the
/// absolute paths of changed `*.swift` files that still exist on disk.
/// Return absolute paths of changed Swift files in `workspace_root`.
///
/// Returns an empty vec when:
/// - `workspace_root` is not inside a git repository
/// - the repository has no commits yet
/// - no Swift files have changed relative to HEAD
/// - git is not installed
/// Sources:
/// - tracked changes vs `HEAD` (modified/renamed/deleted)
/// - untracked Swift files (new files not yet committed)
///
/// Deleted files are intentionally kept in the result so callers can
/// reconstruct the old fragment from `HEAD` and emit removed nodes/edges.
pub fn changed_swift_files(workspace_root: &Path) -> Vec<PathBuf> {
let output = match Command::new("git")
.args(["diff", "--name-only", "HEAD"])
let mut rel_paths: BTreeSet<PathBuf> = BTreeSet::new();

// Tracked deltas compared to HEAD.
// If HEAD does not exist yet, this command fails; that's okay because we
// still collect untracked files below.
if let Ok(lines) = git_lines(
workspace_root,
&["diff", "--name-only", "HEAD", "--", "*.swift"],
) {
rel_paths.extend(lines.into_iter().map(PathBuf::from));
}

// Newly created, untracked Swift files.
if let Ok(lines) = git_lines(
workspace_root,
&[
"ls-files",
"--others",
"--exclude-standard",
"--",
"*.swift",
],
) {
rel_paths.extend(lines.into_iter().map(PathBuf::from));
}

rel_paths
.into_iter()
.map(|rel| workspace_root.join(rel))
.collect()
}

fn git_lines(workspace_root: &Path, args: &[&str]) -> Result<Vec<String>, ()> {
let output = Command::new("git")
.args(args)
.current_dir(workspace_root)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
.map_err(|_| ())?;

if !output.status.success() {
return Vec::new();
return Err(());
}

String::from_utf8_lossy(&output.stdout)
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| l.ends_with(".swift"))
.map(|l| workspace_root.join(l))
.filter(|p| p.exists()) // skip deleted files (removed from disk)
.collect()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}

/// Fetch the content of `file_path` at `HEAD` via `git show HEAD:<relpath>`.
Expand Down Expand Up @@ -57,8 +88,19 @@ pub fn head_content(workspace_root: &Path, file_path: &Path) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;

fn git_ok(root: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(root)
.status()
.expect("failed to run git");
assert!(status.success(), "git {:?} failed", args);
}

#[test]
fn test_changed_swift_files_non_git_dir() {
// Directory that is not a git repo → must return empty, not panic
Expand All @@ -67,6 +109,49 @@ mod tests {
assert!(result.is_empty());
}

#[test]
fn test_changed_swift_files_includes_untracked_modified_deleted() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();

git_ok(root, &["init"]);
git_ok(root, &["config", "user.email", "flowmap-test@example.com"]);
git_ok(root, &["config", "user.name", "FlowMap Test"]);

let modified = root.join("Modified.swift");
let deleted = root.join("Deleted.swift");
let untracked = root.join("Untracked.swift");

fs::write(&modified, "func a() {}\n").unwrap();
fs::write(&deleted, "func b() {}\n").unwrap();
git_ok(root, &["add", "."]);
git_ok(root, &["commit", "-m", "init"]);

fs::write(&modified, "func a() { print(1) }\n").unwrap();
fs::remove_file(&deleted).unwrap();
fs::write(&untracked, "func c() {}\n").unwrap();
fs::write(root.join("README.md"), "ignore\n").unwrap();

let result = changed_swift_files(root);
assert!(result.contains(&modified));
assert!(result.contains(&deleted));
assert!(result.contains(&untracked));
assert!(!result.contains(&root.join("README.md")));
}

#[test]
fn test_changed_swift_files_in_repo_without_head_includes_untracked_swift() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();

git_ok(root, &["init"]);
let file = root.join("BrandNew.swift");
fs::write(&file, "func brandNew() {}\n").unwrap();

let result = changed_swift_files(root);
assert!(result.contains(&file));
}

#[test]
fn test_head_content_non_git_dir() {
let tmp = TempDir::new().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/graph_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub struct BuiltEdge {

/// The complete workspace-level dependency graph produced by merging
/// per-file `SwiftGraph`s.
#[derive(Debug, Default, Serialize)]
#[derive(Debug, Default, Clone, Serialize)]
pub struct BuiltGraph {
pub nodes: Vec<BuiltNode>,
pub edges: Vec<BuiltEdge>,
Expand Down
55 changes: 34 additions & 21 deletions crates/engine/src/impact_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ use std::collections::{HashMap, HashSet, VecDeque};
/// A → B → C
/// impacted_nodes(graph, ["A"]) == [B, C]
/// ```
pub fn impacted_nodes(graph: &BuiltGraph, start_node_ids: &[&str]) -> Vec<BuiltNode> {
// Build adjacency map: from → [to] over "calls" edges only
pub fn impacted_nodes(
graph: &BuiltGraph,
start_node_ids: &[&str],
exclude_node_ids: &[&str],
) -> Vec<BuiltNode> {
// Build adjacency map: to → [from] over "calls" edges only.
// Changing a node impacts its callers, so we must traverse edges BACKWARDS.
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &graph.edges {
if edge.kind == "calls" {
adj.entry(edge.from.as_str())
adj.entry(edge.to.as_str())
.or_default()
.push(edge.to.as_str());
.push(edge.from.as_str());
}
}

Expand Down Expand Up @@ -55,11 +60,12 @@ pub fn impacted_nodes(graph: &BuiltGraph, start_node_ids: &[&str]) -> Vec<BuiltN
}
}

// Exclude the start nodes themselves — only downstream dependants
let excludes: HashSet<&str> = exclude_node_ids.iter().copied().collect();
// Exclude the nodes explicitly requested to be excluded — only downstream dependants
visited
.into_iter()
.filter(|id| !starts.contains(id))
.filter_map(|id| node_map.get(id).map(|&n| n.clone()))
.filter(|id| !excludes.contains(id))
.filter_map(|id| node_map.get(id).copied().cloned())
.collect()
}

Expand Down Expand Up @@ -110,28 +116,34 @@ mod tests {

#[test]
fn test_linear_propagation() {
// A → B → C: changing A must impact B and C
// A → B → C: changing C impacts B and A (Callers are impacted)
let g = build(
vec![node("A"), node("B"), node("C")],
vec![calls("A", "B"), calls("B", "C")],
);
assert_eq!(sorted_ids(impacted_nodes(&g, &["A"])), vec!["B", "C"]);
assert_eq!(
sorted_ids(impacted_nodes(&g, &["C"], &["C"])),
vec!["A", "B"]
);
}

#[test]
fn test_branching_propagation() {
// AB, AC
// BA, CA (B and C call A)
let g = build(
vec![node("A"), node("B"), node("C")],
vec![calls("A", "B"), calls("A", "C")],
vec![calls("B", "A"), calls("C", "A")],
);
assert_eq!(
sorted_ids(impacted_nodes(&g, &["A"], &["A"])),
vec!["B", "C"]
);
assert_eq!(sorted_ids(impacted_nodes(&g, &["A"])), vec!["B", "C"]);
}

#[test]
fn test_no_outgoing_edges() {
fn test_no_incoming_edges() {
let g = build(vec![node("A"), node("B")], vec![]);
assert!(impacted_nodes(&g, &["A"]).is_empty());
assert!(impacted_nodes(&g, &["A"], &["A"]).is_empty());
}

#[test]
Expand All @@ -141,32 +153,33 @@ mod tests {
vec![node("A"), node("B")],
vec![calls("A", "B"), calls("B", "A")],
);
// Only B is downstream of A (A is the start and excluded from result)
assert_eq!(sorted_ids(impacted_nodes(&g, &["A"])), vec!["B"]);
// B and A call each other. B is impacted by A, A is impacted by B.
assert_eq!(sorted_ids(impacted_nodes(&g, &["A"], &["A"])), vec!["B"]);
}

#[test]
fn test_only_calls_edges_followed() {
// "contains" edge should NOT be traversed
// A contains B. Changing B should not impact A through contains.
let g = build(vec![node("A"), node("B")], vec![contains_edge("A", "B")]);
assert!(impacted_nodes(&g, &["A"]).is_empty());
assert!(impacted_nodes(&g, &["B"], &["B"]).is_empty());
}

#[test]
fn test_multiple_start_nodes() {
// Start from both A and D; B and C reachable from A; E reachable from D
// B -> A, C -> B, E -> D
let g = build(
vec![node("A"), node("B"), node("C"), node("D"), node("E")],
vec![calls("A", "B"), calls("B", "C"), calls("D", "E")],
vec![calls("B", "A"), calls("C", "B"), calls("E", "D")],
);
let mut ids = sorted_ids(impacted_nodes(&g, &["A", "D"]));
let mut ids = sorted_ids(impacted_nodes(&g, &["A", "D"], &["A", "D"]));
ids.sort();
assert_eq!(ids, vec!["B", "C", "E"]);
}

#[test]
fn test_unknown_start_node_returns_empty() {
let g = build(vec![node("A")], vec![]);
assert!(impacted_nodes(&g, &["Z"]).is_empty());
assert!(impacted_nodes(&g, &["Z"], &["Z"]).is_empty());
}
}
Loading