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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ $ ./target/release/oot adjudicate --change feature/auth-refactor \
dispute-02: private path secrets/.env touched by @you (secrets/.env) [visibility]

verdict: ▶ CLOAKED . 1 requires review, cloaked
embargo: patch held for maintainers until 2026-09-01
embargo: patch held for maintainers until 2027-09-01

[a]ccept · [r]eject · [d]ocket
```
Expand Down
2 changes: 1 addition & 1 deletion fixtures/visibility.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
private_paths = ["secrets/", ".env"]
embargo_until = "2026-09-01"
embargo_until = "2027-09-01"
private_branches = []
11 changes: 8 additions & 3 deletions src/engine/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,14 @@ pub fn registry() -> Vec<LangConfig> {
name: "rust",
extensions: &["rs"],
language: tree_sitter_rust::LANGUAGE.into(),
function_kinds: &[FunctionKind {
node_kind: "function_item",
}],
function_kinds: &[
FunctionKind {
node_kind: "function_item",
},
FunctionKind {
node_kind: "macro_definition",
},
],
wrapped_functions: &[],
},
LangConfig {
Expand Down
113 changes: 110 additions & 3 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ impl Engine {
Severity::Review,
));
}

// Top-level module code or non-function statements changed.
// Definition bodies are blanked before comparing, so a
// top-level injection hiding beside a function edit still
// gets its own dispute, while whitespace-only churn and
// pure function-body edits stay silent here.
let top_changed = squash_ws(&without_defs(&b, &base_fns))
!= squash_ws(&without_defs(&h, &head_fns));
if top_changed {
disputes.push(meaning(
&mut n,
path,
1,
"file content modified (top-level or non-function definitions)"
.to_string(),
Severity::Review,
));
}
}
(Some(_), None) => {
disputes.push(meaning(
Expand Down Expand Up @@ -473,6 +491,24 @@ impl Engine {
Severity::Review,
));
}
// Incoming top-level changes with no function footprint
// must not slip through: same fallback as the 2-way path,
// scoped to what theirs changed against base. Ours is the
// trusted target side, so ours-only drift stays silent,
// and identical changes on both sides count as converged.
if t_src != b_src && o_src != t_src {
let top_changed = squash_ws(&without_defs(&b_src, &b_fns))
!= squash_ws(&without_defs(&t_src, &t_fns));
if top_changed {
disputes.push(meaning(
&mut n,
path,
1,
"incoming branch modified file content (top-level or non-function definitions)".to_string(),
Severity::Review,
));
}
}
}
// File deleted in target, modified in incoming
(Some(_), None, Some(_)) => {
Expand Down Expand Up @@ -521,12 +557,15 @@ impl Engine {
}
}

/// One extracted definition: source text, 1-based row, and a rename
/// signature (the body with its own name blanked out).
/// One extracted definition: source text, 1-based start row, and the
/// byte span (`start_byte`..`end_byte`) of the definition in its file,
/// plus a rename signature (the body with its own name blanked out).
#[derive(Debug, Clone, PartialEq, Eq)]
struct FnDef {
src: String,
row: usize,
start_byte: usize,
end_byte: usize,
signature: String,
}

Expand All @@ -536,6 +575,36 @@ struct FnDef {
/// instead of collapsing to the first occurrence.
type FunctionMap = HashMap<String, Vec<FnDef>>;

/// The file with every extracted definition's byte span removed,
/// leaving top-level module code behind. Defs are blanked by their
/// exact source span rather than whole lines, so a statement stapled to
/// the same line as a closing brace survives and still gets disputed.
/// Byte spans come from the same tree-sitter buffer the source was
/// parsed from, so they align to UTF-8 char boundaries.
fn without_defs(src: &str, fns: &FunctionMap) -> String {
let bytes = src.as_bytes();
let mut marked = vec![false; bytes.len()];
for def in fns.values().flatten() {
let start = def.start_byte.min(bytes.len());
let end = def.end_byte.min(bytes.len()).max(start);
for slot in marked.iter_mut().take(end).skip(start) {
*slot = true;
}
}
let kept: Vec<u8> = bytes
.iter()
.enumerate()
.filter(|(i, _)| !marked[*i])
.map(|(_, b)| *b)
.collect();
String::from_utf8_lossy(&kept).into_owned()
}

/// Strip all whitespace for churn-insensitive comparison.
fn squash_ws(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}

/// Align two def lists for one name: identical source text pairs first
/// (each def consumed once, silently — those are unchanged), then remaining
/// leftovers pair by relative order and count as changed. Returns matched
Expand Down Expand Up @@ -808,6 +877,38 @@ fn insert(
};
let src = src.to_string();
let row = body.start_position().row + 1;
// Blanking span: climb through single-statement wrappers so an added
// `const f = ...;` leaves no `const ;` crumbs that fake a top-level
// dispute. Multi-declarator parents (`const a=.., b=..`) stay put so a
// sibling edit cannot hide. Same-line injections are separate statement
// nodes, so they still survive blanking and get disputed.
let mut span_node = body;
while let Some(parent) = span_node.parent() {
match parent.kind() {
"lexical_declaration" | "variable_declaration" => {
let mut declarators = 0;
for i in 0..parent.child_count() {
if parent
.child(i)
.is_some_and(|c| c.kind() == "variable_declarator")
{
declarators += 1;
}
}
if declarators == 1 {
span_node = parent;
} else {
break;
}
}
"export_statement" | "expression_statement" => {
span_node = parent;
}
_ => break,
}
}
let start_byte = span_node.start_byte();
let end_byte = span_node.end_byte();
// Signature: the body with its own name blanked, so two functions
// that differ only by what they are called compare equal and pair
// as a rename.
Expand Down Expand Up @@ -837,6 +938,8 @@ fn insert(
map.entry(key).or_default().push(FnDef {
src,
row,
start_byte,
end_byte,
signature,
});
true
Expand Down Expand Up @@ -1063,7 +1166,7 @@ mod tests {

let disputes = eng.diff_snapshots(&base, &head).unwrap();
assert!(
disputes.is_empty(),
!disputes.iter().any(|d| d.detail.contains("`handle`")),
"member-expression assignment should not be treated as a named function"
);
}
Expand Down Expand Up @@ -1328,6 +1431,8 @@ mod tests {
FnDef {
src: String::new(),
row: 1,
start_byte: 0,
end_byte: 0,
signature: sig.to_string(),
},
)
Expand All @@ -1339,6 +1444,8 @@ mod tests {
FnDef {
src: String::new(),
row,
start_byte: 0,
end_byte: 0,
signature: sig.to_string(),
},
)
Expand Down
20 changes: 16 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ fn main() -> anyhow::Result<std::process::ExitCode> {
Commands::Record { message, branch } => {
let root = std::env::current_dir()?;
let store = Store::open(&root)?;
let _lock = store.lock()?;
let branch = resolve_branch(&store, branch)?;

let (author, committer) = resolve_identity(&root)?;
Expand Down Expand Up @@ -450,6 +451,7 @@ fn main() -> anyhow::Result<std::process::ExitCode> {
let id = store.put_record(&record)?;
store.index_push(&id)?;
store.set_ref(&branch, &id)?;
let _ = store.set_head_branch(&branch);

println!(
"recorded {id} on {branch} as {kind}: {} file(s)",
Expand Down Expand Up @@ -525,9 +527,17 @@ fn main() -> anyhow::Result<std::process::ExitCode> {
Commands::Log { branch } => {
let store = Store::open(".")?;
let branch = resolve_branch(&store, branch)?;
let head_id = store
.head_id(&branch)?
.ok_or_else(|| anyhow::anyhow!("branch '{branch}' has no changes"))?;
let head_id = match store.head_id(&branch)? {
Some(id) => id,
None => {
if store.refs()?.is_empty() {
println!("branch '{branch}': empty store, nothing recorded yet");
return Ok(std::process::ExitCode::SUCCESS);
} else {
anyhow::bail!("branch '{branch}' has no changes");
}
}
};

// Reachable set from the head...
let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
Expand Down Expand Up @@ -629,10 +639,12 @@ fn main() -> anyhow::Result<std::process::ExitCode> {
}
}

// Point HEAD at the first exported branch so `git log` works immediately.
// Point HEAD at the first exported branch so `git log` works immediately,
// and populate the working tree files so the exported repo is ready to inspect.
if let Some(first_branch) = first_exported_branch {
let first = format!("refs/heads/{first_branch}");
run_git(&["symbolic-ref", "HEAD", &first], &out_path)?;
let _ = run_git(&["checkout", "-f", "HEAD"], &out_path);
} else {
println!(
"warning: all branches were withheld by policy; no refs exported to {out}"
Expand Down
Loading
Loading