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: 4 additions & 7 deletions crates/comment-checker/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use crate::Verdict;
use crate::classify::classify;
use crate::comment::Comment;
use crate::comment::{Comment, PositionRole};
use crate::detect::detect_comments;
use crate::hook::{HookInput, decode};
use crate::report::{Flagged, format_report};
Expand Down Expand Up @@ -67,16 +67,13 @@ fn new_comments(old: &str, new: &str, file_path: &str) -> Vec<Comment> {
detect_comments(new, file_path)
.into_iter()
.filter(|comment| !old_texts.contains(&normalize(comment)))
.map(mark_unreliable)
.map(mark_fragment_edge_context)
.collect()
}

/// Mark a comment's context as unreliable — the fragment edge of an Edit or
/// `MultiEdit` may have lost adjacent-code context, so the verifier must
/// fall back from restate detection rather than convict.
fn mark_unreliable(mut comment: Comment) -> Comment {
fn mark_fragment_edge_context(mut comment: Comment) -> Comment {
if let Some(ctx) = comment.context.as_mut() {
ctx.unreliable = true;
ctx.unreliable = ctx.adjacent_code.is_none() || ctx.position == PositionRole::Trailing;
}
comment
}
Expand Down
22 changes: 22 additions & 0 deletions crates/comment-checker/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ use claude_code_comment_checker::{
};
use serde::Deserialize;

fn escape(field: &str) -> String {
field
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}

pub fn write_payload(file_path: &str, content: &str) -> String {
let content = escape(content);
format!(
r#"{{"tool_name":"Write","tool_input":{{"file_path":"{file_path}","content":"{content}"}}}}"#
)
}

pub fn edit_payload(file_path: &str, old_string: &str, new_string: &str) -> String {
let old_string = escape(old_string);
let new_string = escape(new_string);
format!(
r#"{{"tool_name":"Edit","tool_input":{{"file_path":"{file_path}","old_string":"{old_string}","new_string":"{new_string}"}}}}"#
)
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Label {
Unnecessary,
Expand Down
48 changes: 48 additions & 0 deletions crates/comment-checker/tests/edit_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! The Edit-path gate: every corpus case driven through `check` as an `Edit`
//! payload. `f1.rs` exercises the `Write` path only, so before this gate the
//! `Edit`/`MultiEdit` path carried no corpus coverage — which is how a blanket
//! fragment acquittal shipped while every other gate stayed green.

mod common;

use claude_code_comment_checker::{Outcome, check};
use common::{Case, Label, edit_payload, load_corpus, synthesize_source, synthesized_path};

fn blocks(case: &Case) -> bool {
let payload = edit_payload(
&synthesized_path(case),
&format!("{}\n", case.code),
&synthesize_source(case),
);
matches!(check(&payload, ""), Outcome::Block { .. })
}

#[test]
fn edit_path_never_blocks_a_justified_comment() {
let offenders: Vec<_> = load_corpus()
.into_iter()
.filter(|case| case.label == Label::Justified && blocks(case))
.map(|case| format!("[{}/{}] {}", case.kind, case.language, case.text))
.collect();
assert!(
offenders.is_empty(),
"{} justified corpus case(s) blocked on the Edit path:\n {}",
offenders.len(),
offenders.join("\n ")
);
}

#[test]
fn edit_path_catches_every_unnecessary_corpus_case() {
let missed: Vec<_> = load_corpus()
.into_iter()
.filter(|case| case.label == Label::Unnecessary && !blocks(case))
.map(|case| format!("[{}/{}] {}", case.kind, case.language, case.text))
.collect();
assert!(
missed.is_empty(),
"{} unnecessary corpus case(s) spared on the Edit path:\n {}",
missed.len(),
missed.join("\n ")
);
}
43 changes: 19 additions & 24 deletions crates/comment-checker/tests/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,9 @@

use claude_code_comment_checker::{Outcome, check};

fn write(file_path: &str, content: &str) -> String {
let content = content
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n");
format!(
r#"{{"tool_name":"Write","tool_input":{{"file_path":"{file_path}","content":"{content}"}}}}"#
)
}
mod common;

use common::write_payload as write;

#[test]
fn unnecessary_comment_blocks() {
Expand Down Expand Up @@ -61,30 +55,31 @@ fn edit_new_comment_blocks() {
}

#[test]
fn edit_fragment_context_is_never_relied_upon() {
// U2 scenario: an Edit sees only the fragment, so a comment that would
// restate its adjacent code must NOT be convicted — the text-only floor
// downgrades, the hook passes, and the user is not blocked on context the
// fragment cannot vouch for.
fn edit_new_restatement_with_code_after_it_blocks() {
let input = r#"{"tool_name":"Edit","tool_input":{"file_path":"foo.py","old_string":"counter = 0\n","new_string":"counter = 0\n# increment the counter\ncounter += 1\n"}}"#;
assert!(
matches!(check(input, ""), Outcome::Pass { .. }),
"an Edit fragment whose context is unreliable must fall back, not convict"
);
assert!(matches!(check(input, ""), Outcome::Block { .. }));
}

#[test]
fn multi_edit_new_restatement_comment_passes() {
// MultiEdit fragments share Edit's unreliable-context rule: a would-be
// restatement introduced by an edit is spared, not convicted.
let input = r#"{"tool_name":"MultiEdit","tool_input":{"file_path":"foo.py","edits":[{"old_string":"x = 1\n","new_string":"x = 1\n# increment the counter\ncounter += 1\n"}]}}"#;
fn edit_comment_at_the_fragment_tail_passes() {
let input = r#"{"tool_name":"Edit","tool_input":{"file_path":"foo.py","old_string":"counter = 0\n","new_string":"counter = 0\n# increment the counter\n"}}"#;
assert!(matches!(check(input, ""), Outcome::Pass { .. }));
}

#[test]
fn edit_comment_with_no_adjacent_code_passes() {
let input = r##"{"tool_name":"Edit","tool_input":{"file_path":"foo.py","old_string":"","new_string":"# increment the counter\n"}}"##;
assert!(matches!(check(input, ""), Outcome::Pass { .. }));
}

#[test]
fn multi_edit_new_restatement_comment_blocks() {
let input = r#"{"tool_name":"MultiEdit","tool_input":{"file_path":"foo.py","edits":[{"old_string":"x = 1\n","new_string":"x = 1\n# increment the counter\ncounter += 1\n"}]}}"#;
assert!(matches!(check(input, ""), Outcome::Block { .. }));
}

#[test]
fn multi_edit_new_todo_comment_blocks() {
// Explicit text-only rules still block on MultiEdit: the unreliable
// downgrade only spares the restate fallback, never a real rule match.
let input = r#"{"tool_name":"MultiEdit","tool_input":{"file_path":"foo.py","edits":[{"old_string":"x = 1\n","new_string":"x = 1\n# TODO: handle\n"}]}}"#;
assert!(matches!(check(input, ""), Outcome::Block { .. }));
}
Expand Down
Loading