diff --git a/.changeset/strip-flagged-comments.md b/.changeset/strip-flagged-comments.md new file mode 100644 index 0000000..35b64ec --- /dev/null +++ b/.changeset/strip-flagged-comments.md @@ -0,0 +1,5 @@ +--- +'@systemfsoftware/claude-code-comment-checker': minor +--- + +`--strip` deletes whole-line flagged comments from the file named in the hook payload. Without the flag, the hook still only reports. After a strip, the message names a code change to make — rename, extract, or tighten a type — instead of asking you to delete the comment. diff --git a/Cargo.lock b/Cargo.lock index 13d8233..0143a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,7 +201,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "claude-code-comment-checker" -version = "0.1.8" +version = "0.2.0" dependencies = [ "clap", "proptest", diff --git a/README.md b/README.md index a01024f..794a124 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Comment-checker is a `PostToolUse` hook for Claude Code that flags unnecessary code comments and states the exact reason each one fails. It is an alternative to flag-everything comment linters that train agents to ignore warnings: a tree-sitter classifier over 37 languages, gated to F1 ≥ 0.85 on a 60-case corpus, that spares public API docs, directives, and non-obvious intent. -It never edits your files, never sends code anywhere, and exits deterministically so the hook can gate automation. +Without `--strip` it never edits your files. It never sends code anywhere, and it exits deterministically so the hook can gate automation. ```bash pnpm add -g @systemfsoftware/claude-code-comment-checker @@ -143,6 +143,20 @@ comment-checker --prompt "Review feedback:\n\n{{comments}}\n\nRevise the code." } ``` +### Strip flagged comments + +Pass `--strip` to delete whole-line flagged comments from the file named in the payload. Trailing and inline comments (those sharing a line with code) stay in the file and are reported. If the file is missing, `--strip` is report-only — same as the default. + +```bash +comment-checker --strip +``` + +```json +{ + "hooks": { "PostToolUse": [ { "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "comment-checker --strip" } ] } ] } +} +``` + ### Exit codes Deterministic, and the reason sessions and scripts can gate on the hook: @@ -158,7 +172,7 @@ Deterministic, and the reason sessions and scripts can gate on the hook: A: Make sure the package manager's global bin directory is on `PATH`. Check with `pnpm bin -g` (or `npm bin -g`); npm global bins can otherwise land outside the shell path on some setups. **Q: Does it modify my files?** -A: No. It reads a hook payload over stdin and prints a report; nothing is written to disk. +A: Not unless you pass `--strip`. The default reads a hook payload over stdin and prints a report. `--strip` deletes whole-line flagged comments from the named file; trailing and inline comments are left in place. **Q: Does it send my code anywhere?** A: No network requests at all. The binary is fully offline. diff --git a/crates/comment-checker/src/check.rs b/crates/comment-checker/src/check.rs index 488205a..447e135 100644 --- a/crates/comment-checker/src/check.rs +++ b/crates/comment-checker/src/check.rs @@ -5,13 +5,18 @@ use crate::classify::classify; use crate::comment::{Comment, PositionRole}; use crate::detect::detect_comments; use crate::hook::{HookInput, decode}; -use crate::report::{Flagged, format_report}; +use crate::report::{Finding, Flagged, format_report, reason_text}; /// The outcome of a hook check: pass (with a note) or block (with a report). #[derive(Debug, Eq, PartialEq)] pub enum Outcome { - Pass { note: String }, - Block { report: String }, + Pass { + note: String, + }, + Block { + report: String, + findings: Vec, + }, } /// Run the check over the raw hook JSON. @@ -26,16 +31,41 @@ pub fn check(input: &str, custom_prompt: &str) -> Outcome { } let comments = detect_for(&hook, file_path); - let flagged = flag_unnecessary(&comments); + decide(file_path, &comments, custom_prompt) +} + +/// Run the check over file contents as a completed `Write`. +#[must_use] +pub fn check_source(file_path: &str, content: &str, custom_prompt: &str) -> Outcome { + let comments = detect_comments(content, file_path); + decide(file_path, &comments, custom_prompt) +} + +fn decide(file_path: &str, comments: &[Comment], custom_prompt: &str) -> Outcome { + let flagged = flag_unnecessary(comments); if flagged.is_empty() { return pass("No unnecessary comments found"); } + let findings = findings_from(&flagged); Outcome::Block { report: format_report(&flagged, file_path, custom_prompt), + findings, } } +fn findings_from(flagged: &[Flagged<'_>]) -> Vec { + flagged + .iter() + .map(|flag| Finding { + line_number: flag.comment.line_number, + text: flag.comment.text.clone(), + reason: reason_text(&flag.kind), + position: flag.comment.context.as_ref().map(|ctx| ctx.position), + }) + .collect() +} + /// The content a tool writes, and for edits only the newly-added comments. fn detect_for(hook: &HookInput, file_path: &str) -> Vec { match hook.tool_name.as_str() { diff --git a/crates/comment-checker/src/lib.rs b/crates/comment-checker/src/lib.rs index 88984a4..b7927d3 100644 --- a/crates/comment-checker/src/lib.rs +++ b/crates/comment-checker/src/lib.rs @@ -2,11 +2,12 @@ //! //! A Claude Code `PostToolUse` hook: it reads the hook payload, detects the //! comments in the just-written code, classifies each as justified or -//! unnecessary, and blocks (exit 2) when any are unnecessary. +//! unnecessary, and blocks (exit 2) when any are unnecessary. Pass `--strip` +//! to delete whole-line flagged comments from the file on disk. //! //! Split along the functional-core/imperative-shell seam (CONST-B1): the pure //! core is [`classify`], and the shell reads stdin, drives tree-sitter -//! ([`detect`]), and writes the report. +//! ([`detect`]), and writes the report — or, with `--strip`, the file. pub mod check; pub mod classify; @@ -15,10 +16,13 @@ pub mod detect; pub mod hook; pub mod language; pub mod report; +pub mod strip; -pub use check::{Outcome, check}; +pub use check::{Outcome, check, check_source}; pub use classify::classify; pub use comment::{ Comment, CommentContext, CommentType, Justification, PositionRole, RestateEvidence, Scope, UnnecessaryKind, Verdict, }; +pub use report::{Finding, format_strip_report}; +pub use strip::{StripPlan, plan_strip}; diff --git a/crates/comment-checker/src/main.rs b/crates/comment-checker/src/main.rs index f06da07..4635ada 100644 --- a/crates/comment-checker/src/main.rs +++ b/crates/comment-checker/src/main.rs @@ -4,7 +4,8 @@ use std::io::Read; use std::process::ExitCode; use clap::Parser; -use claude_code_comment_checker::{Outcome, check}; +use claude_code_comment_checker::hook::decode; +use claude_code_comment_checker::{Outcome, check, check_source, format_strip_report, plan_strip}; /// A hook that flags unnecessary code comments. #[derive(Parser)] @@ -13,19 +14,76 @@ struct Cli { /// Replace the default warning message; `{{comments}}` inserts the report. #[arg(long)] prompt: Option, + /// Delete whole-line flagged comments from the file named in the payload. + /// + /// Trailing and inline comments are reported, not cut. A missing file is + /// report-only, same as without this flag. + #[arg(long)] + strip: bool, } fn main() -> ExitCode { let cli = Cli::parse(); let mut input = String::new(); let _ = std::io::stdin().read_to_string(&mut input); + let prompt = cli.prompt.as_deref().unwrap_or_default(); + + if cli.strip { + run_strip(&input, prompt) + } else { + emit_outcome(check(&input, prompt)) + } +} + +fn emit_outcome(outcome: Outcome) -> ExitCode { + match outcome { + Outcome::Pass { note } => { + emit_to_debug_log(¬e); + ExitCode::from(0) + } + Outcome::Block { report, .. } => { + emit_to_model(&report); + ExitCode::from(2) + } + } +} + +fn run_strip(input: &str, prompt: &str) -> ExitCode { + let Some(hook) = decode(input) else { + return emit_outcome(check(input, prompt)); + }; + let file_path = hook.tool_input.file_path.as_str(); + if file_path.is_empty() { + return emit_outcome(check(input, prompt)); + } + let Ok(on_disk) = std::fs::read_to_string(file_path) else { + return emit_outcome(check(input, prompt)); + }; - match check(&input, cli.prompt.as_deref().unwrap_or_default()) { + match check_source(file_path, &on_disk, prompt) { Outcome::Pass { note } => { emit_to_debug_log(¬e); ExitCode::from(0) } - Outcome::Block { report } => { + Outcome::Block { findings, .. } => { + let plan = plan_strip(&on_disk, &findings); + if plan.changed() { + if let Err(err) = std::fs::write(file_path, &plan.source) { + emit_to_model(&format!( + "comment-checker --strip could not write {file_path}: {err}\n" + )); + return ExitCode::from(2); + } + } + let remaining = match check_source(file_path, &plan.source, prompt) { + Outcome::Pass { .. } => Vec::new(), + Outcome::Block { findings, .. } => findings, + }; + let report = format_strip_report(file_path, &plan.deleted, &remaining, prompt); + if report.is_empty() { + emit_to_debug_log("[check-comments] Skipping: No unnecessary comments found\n"); + return ExitCode::from(0); + } emit_to_model(&report); ExitCode::from(2) } diff --git a/crates/comment-checker/src/report.rs b/crates/comment-checker/src/report.rs index 53b6621..13e3f91 100644 --- a/crates/comment-checker/src/report.rs +++ b/crates/comment-checker/src/report.rs @@ -1,6 +1,6 @@ //! Shape the warning report (CONST-B3: shape is pure). -use crate::comment::{Comment, UnnecessaryKind}; +use crate::comment::{Comment, PositionRole, UnnecessaryKind}; /// A comment the classifier marked unnecessary, kept for the report. #[derive(Clone, Debug)] @@ -8,6 +8,16 @@ pub struct Flagged<'a> { pub comment: &'a Comment, pub kind: UnnecessaryKind, } + +/// An owned flagged comment the shell can strip or reprint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Finding { + pub line_number: usize, + pub text: String, + pub reason: String, + pub position: Option, +} + #[must_use] pub fn format_report(flagged: &[Flagged<'_>], file_path: &str, custom_prompt: &str) -> String { let header = format!( @@ -36,7 +46,59 @@ pub fn format_report(flagged: &[Flagged<'_>], file_path: &str, custom_prompt: &s "one, make the code self-explanatory instead — better names, extraction,".to_string(), ); lines.push("a clearer type — and do not re-add the comment.".to_string()); - let report = lines.join("\n"); + apply_prompt(lines.join("\n"), custom_prompt) +} + +/// Residual message after `--strip`: what was deleted, and what still needs a rewrite. +#[must_use] +pub fn format_strip_report( + file_path: &str, + deleted: &[Finding], + remaining: &[Finding], + custom_prompt: &str, +) -> String { + let mut lines = Vec::new(); + if !deleted.is_empty() { + lines.push(format!( + "Deleted {} comment(s) from {file_path}. The code has to carry that meaning now:", + deleted.len() + )); + for finding in deleted { + let first = finding.text.lines().next().unwrap_or("").trim(); + lines.push(format!(" was: {first} — {}", finding.reason)); + } + lines.push(String::new()); + lines.push( + "Do this: rename the identifiers, extract the expression, or tighten the type until the deleted text would be redundant.".to_string(), + ); + } + if !remaining.is_empty() { + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(format!( + "{} comment(s) in {file_path} need a rewrite rather than a cut (trailing or inline):", + remaining.len() + )); + for finding in remaining { + let first = finding.text.lines().next().unwrap_or("").trim(); + lines.push(format!( + " line {} — {first} — {}", + finding.line_number, finding.reason + )); + } + lines.push(String::new()); + lines.push( + "Do this: change those lines so the comment has nothing left to state.".to_string(), + ); + } + if lines.is_empty() { + return String::new(); + } + apply_prompt(format!("{}\n", lines.join("\n")), custom_prompt) +} + +fn apply_prompt(report: String, custom_prompt: &str) -> String { if custom_prompt.is_empty() { report } else { @@ -44,7 +106,7 @@ pub fn format_report(flagged: &[Flagged<'_>], file_path: &str, custom_prompt: &s } } -fn reason_text(kind: &UnnecessaryKind) -> String { +pub(crate) fn reason_text(kind: &UnnecessaryKind) -> String { match kind { UnnecessaryKind::NarratesControlFlow { construct, .. } => { format!("narrates the {construct} construct the code already shows") diff --git a/crates/comment-checker/src/strip.rs b/crates/comment-checker/src/strip.rs new file mode 100644 index 0000000..0fc42f3 --- /dev/null +++ b/crates/comment-checker/src/strip.rs @@ -0,0 +1,264 @@ +//! Pure strip of whole-line flagged comments (CONST-B1: no I/O). + +use crate::comment::PositionRole; +use crate::report::Finding; + +/// Rewritten source plus which findings were cut versus left for a rewrite. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StripPlan { + pub source: String, + pub deleted: Vec, + pub remaining: Vec, +} + +impl StripPlan { + #[must_use] + pub fn changed(&self) -> bool { + !self.deleted.is_empty() + } +} + +/// Remove whole-line flagged comments from `source`. +/// +/// A finding is cut only when its text equals the occupied source lines +/// (trimmed) and its position is not trailing or inline. Shebang lines are +/// never cut. Everything else stays in `remaining`. +/// +/// Line endings in `source` are preserved (`\r\n` vs `\n`). Comment markers +/// are not interpreted here: occupancy comes from the finding's own source +/// text, which tree-sitter already extracted for that language. +#[must_use] +pub fn plan_strip(source: &str, findings: &[Finding]) -> StripPlan { + let ending = line_ending(source); + let lines: Vec<&str> = source.lines().collect(); + let mut cut = vec![false; lines.len()]; + let mut deleted = Vec::new(); + let mut remaining = Vec::new(); + + for finding in findings { + match occupied_whole_lines(&lines, finding) { + Some(indices) => { + for index in indices { + cut[index] = true; + } + deleted.push(finding.clone()); + } + None => remaining.push(finding.clone()), + } + } + + let kept: Vec<&str> = lines + .iter() + .enumerate() + .filter_map(|(index, line)| if cut[index] { None } else { Some(*line) }) + .collect(); + let mut rewritten = kept.join(ending); + if source.ends_with('\n') && (rewritten.is_empty() || !rewritten.ends_with(ending)) { + rewritten.push_str(ending); + } + + StripPlan { + source: rewritten, + deleted, + remaining, + } +} + +fn line_ending(source: &str) -> &'static str { + if source.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +fn occupied_whole_lines(lines: &[&str], finding: &Finding) -> Option> { + if matches!( + finding.position, + Some(PositionRole::Trailing | PositionRole::Inline) + ) { + return None; + } + let comment_lines: Vec<&str> = finding.text.split('\n').collect(); + let start = finding.line_number.checked_sub(1)?; + if start == 0 && lines.first().is_some_and(|line| line.starts_with("#!")) { + return None; + } + if start + comment_lines.len() > lines.len() { + return None; + } + for (offset, comment_line) in comment_lines.iter().enumerate() { + if lines[start + offset].trim() != comment_line.trim() { + return None; + } + } + Some( + (0..comment_lines.len()) + .map(|offset| start + offset) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Outcome; + use crate::check::check_source; + use crate::comment::PositionRole; + + fn finding(line: usize, text: &str, position: Option) -> Finding { + Finding { + line_number: line, + text: text.to_owned(), + reason: "restates what the code already says".to_owned(), + position, + } + } + + #[test] + fn cuts_a_whole_line_comment_and_keeps_the_code() { + let source = "def load(path):\n # Parse the config file\n return path\n"; + let plan = plan_strip( + source, + &[finding( + 2, + "# Parse the config file", + Some(PositionRole::Leading), + )], + ); + assert_eq!(plan.source, "def load(path):\n return path\n"); + assert_eq!(plan.deleted.len(), 1); + assert!(plan.remaining.is_empty()); + } + + #[test] + fn leaves_a_trailing_comment_in_place() { + let source = "x = 1 # TODO: fix this later\n"; + let plan = plan_strip( + source, + &[finding( + 1, + "# TODO: fix this later", + Some(PositionRole::Trailing), + )], + ); + assert_eq!(plan.source, source); + assert!(plan.deleted.is_empty()); + assert_eq!(plan.remaining.len(), 1); + } + + #[test] + fn leaves_a_mismatched_line_in_place_without_position() { + let source = "x = 1 # TODO: fix this later\n"; + let plan = plan_strip(source, &[finding(1, "# TODO: fix this later", None)]); + assert_eq!(plan.source, source); + assert!(plan.deleted.is_empty()); + } + + #[test] + fn never_cuts_a_shebang() { + let source = "#!/usr/bin/env python3\nx = 1\n"; + let plan = plan_strip(source, &[finding(1, "#!/usr/bin/env python3", None)]); + assert_eq!(plan.source, source); + assert!(plan.deleted.is_empty()); + } + + #[test] + fn preserves_a_missing_trailing_newline() { + let source = "x = 1\n# TODO: fix this later"; + let plan = plan_strip( + source, + &[finding( + 2, + "# TODO: fix this later", + Some(PositionRole::Leading), + )], + ); + assert_eq!(plan.source, "x = 1"); + assert!(!plan.source.ends_with('\n')); + } + + #[test] + fn preserves_crlf() { + let source = "# TODO: fix this later\r\nx = 1\r\n"; + let plan = plan_strip( + source, + &[finding( + 1, + "# TODO: fix this later", + Some(PositionRole::Leading), + )], + ); + assert_eq!(plan.source, "x = 1\r\n"); + } + + fn strip_source(path: &str, source: &str) -> String { + let Outcome::Block { findings, .. } = check_source(path, source, "") else { + panic!("{path}: expected a block, source was {source:?}"); + }; + plan_strip(source, &findings).source + } + + #[test] + fn strips_whole_line_todos_for_each_comment_family() { + let cases = [ + ("a.py", "# TODO: fix this later\nx = 1\n", "x = 1\n"), + ("a.js", "// TODO: fix this later\nx = 1\n", "x = 1\n"), + ("a.ts", "// TODO: fix this later\nx = 1\n", "x = 1\n"), + ( + "a.rs", + "// TODO: fix this later\nlet x = 1;\n", + "let x = 1;\n", + ), + ("a.go", "// TODO: fix this later\nx := 1\n", "x := 1\n"), + ( + "a.c", + "// TODO: fix this later\nint x = 1;\n", + "int x = 1;\n", + ), + ("a.rb", "# TODO: fix this later\nx = 1\n", "x = 1\n"), + ("a.sh", "# TODO: fix this later\nx=1\n", "x=1\n"), + ( + "a.lua", + "-- TODO: fix this later\nlocal x = 1\n", + "local x = 1\n", + ), + ( + "a.sql", + "-- TODO: fix this later\nSELECT 1;\n", + "SELECT 1;\n", + ), + ("a.hs", "-- TODO: fix this later\nx = 1\n", "x = 1\n"), + ("a.js", "/* TODO: fix this later */\nx = 1\n", "x = 1\n"), + ( + "a.css", + "/* TODO: fix this later */\nbody { color: red; }\n", + "body { color: red; }\n", + ), + ( + "a.html", + "\n

x

\n", + "

x

\n", + ), + ]; + for (path, source, expected) in cases { + assert_eq!(strip_source(path, source), expected, "{path}: {source:?}"); + } + } + + #[test] + fn strips_a_multiline_block_comment() { + let source = "/* TODO: fix this later\nand also this */\nint x = 1;\n"; + assert_eq!(strip_source("a.c", source), "int x = 1;\n"); + } + + #[test] + fn does_not_cut_jsx_wrapper_around_a_block_comment() { + let source = "const x = 1;\n {/* TODO: fix this later */}\n"; + let Outcome::Block { findings, .. } = check_source("a.tsx", source, "") else { + return; + }; + let plan = plan_strip(source, &findings); + assert_eq!(plan.source, source); + } +} diff --git a/crates/comment-checker/tests/pipeline.rs b/crates/comment-checker/tests/pipeline.rs index c4541ff..61f6c9b 100644 --- a/crates/comment-checker/tests/pipeline.rs +++ b/crates/comment-checker/tests/pipeline.rs @@ -87,7 +87,7 @@ fn multi_edit_new_todo_comment_blocks() { #[test] fn report_names_the_reason() { let input = write("foo.go", "// TODO: refactor later\n"); - let Outcome::Block { report } = check(&input, "") else { + let Outcome::Block { report, .. } = check(&input, "") else { panic!("expected a block"); }; assert!( @@ -101,7 +101,7 @@ fn report_cites_restate_evidence() { // The block reason must show the overlap the verdict was built on, so the // flag is checkable rather than hand-waved. let input = write("foo.rs", "// increment the counter\ncounter += 1;\n"); - let Outcome::Block { report } = check(&input, "") else { + let Outcome::Block { report, .. } = check(&input, "") else { panic!("expected a block"); }; assert!( diff --git a/crates/comment-checker/tests/strip.rs b/crates/comment-checker/tests/strip.rs new file mode 100644 index 0000000..23b87b6 --- /dev/null +++ b/crates/comment-checker/tests/strip.rs @@ -0,0 +1,159 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct Run { + status: std::process::ExitStatus, + stdout: String, + stderr: String, +} + +struct TempPy { + path: PathBuf, +} + +impl TempPy { + fn write(contents: &str) -> Self { + static SEQ: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "comment-checker-strip-{}-{}.py", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&path, contents).expect("write fixture"); + Self { path } + } + + fn read(&self) -> String { + fs::read_to_string(&self.path).expect("read fixture") + } +} + +impl Drop for TempPy { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn run_binary(payload: &str, args: &[&str]) -> Run { + let mut child = Command::new(env!("CARGO_BIN_EXE_comment-checker")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn comment-checker binary"); + child + .stdin + .take() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + let out = child.wait_with_output().expect("wait for binary"); + Run { + status: out.status, + stdout: String::from_utf8(out.stdout).expect("stdout is utf-8"), + stderr: String::from_utf8(out.stderr).expect("stderr is utf-8"), + } +} + +fn write_payload(path: &Path, content: &str) -> String { + serde_json::json!({ + "tool_name": "Write", + "tool_input": { + "file_path": path.to_str().expect("utf-8 path"), + "content": content, + } + }) + .to_string() +} + +fn edit_payload(path: &Path, old: &str, new: &str) -> String { + serde_json::json!({ + "tool_name": "Edit", + "tool_input": { + "file_path": path.to_str().expect("utf-8 path"), + "old_string": old, + "new_string": new, + } + }) + .to_string() +} + +const SLOP: &str = "def load(path):\n # Parse the config file\n return path\n"; +const CLEAN: &str = "def load(path):\n return path\n"; +const TRAILING: &str = "x = 1 # TODO: fix this later\n"; + +#[test] +fn strip_deletes_a_whole_line_comment_and_keeps_the_code() { + let file = TempPy::write(SLOP); + let run = run_binary(&write_payload(&file.path, SLOP), &["--strip"]); + assert_eq!(run.status.code(), Some(2)); + assert_eq!(file.read(), CLEAN); + assert!(run.stderr.contains("Deleted 1 comment(s)")); + assert!(run.stderr.contains("Do this: rename the identifiers")); + assert!(!run.stderr.contains("Action: delete the flagged comments")); + assert!(run.stdout.is_empty()); +} + +#[test] +fn without_strip_the_file_is_unchanged() { + let file = TempPy::write(SLOP); + let run = run_binary(&write_payload(&file.path, SLOP), &[]); + assert_eq!(run.status.code(), Some(2)); + assert_eq!(file.read(), SLOP); + assert!(run.stderr.contains("Action: delete the flagged comments")); +} + +#[test] +fn strip_leaves_a_trailing_comment_and_reports_it() { + let file = TempPy::write(TRAILING); + let run = run_binary(&write_payload(&file.path, TRAILING), &["--strip"]); + assert_eq!(run.status.code(), Some(2)); + assert_eq!(file.read(), TRAILING); + assert!(run.stderr.contains("need a rewrite rather than a cut")); + assert!(run.stderr.contains("Do this: change those lines")); + assert!(!run.stderr.contains("Deleted")); +} + +#[test] +fn strip_on_a_clean_file_is_silent() { + let file = TempPy::write(CLEAN); + let run = run_binary(&write_payload(&file.path, CLEAN), &["--strip"]); + assert!(run.status.success()); + assert_eq!(file.read(), CLEAN); + assert!(run.stderr.is_empty()); +} + +#[test] +fn strip_is_idempotent() { + let file = TempPy::write(SLOP); + let first = run_binary(&write_payload(&file.path, SLOP), &["--strip"]); + assert_eq!(first.status.code(), Some(2)); + let after = file.read(); + let second = run_binary(&write_payload(&file.path, &after), &["--strip"]); + assert!(second.status.success()); + assert_eq!(file.read(), after); +} + +#[test] +fn strip_judges_the_file_on_disk_for_an_edit_payload() { + let file = TempPy::write(SLOP); + let run = run_binary( + &edit_payload(&file.path, "return path\n", SLOP), + &["--strip"], + ); + assert_eq!(run.status.code(), Some(2)); + assert_eq!(file.read(), CLEAN); +} + +#[test] +fn strip_without_a_file_stays_report_only() { + let payload = r#"{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\n # TODO: fix this later\n return path\n"}}"#; + let run = run_binary(payload, &["--strip"]); + assert_eq!(run.status.code(), Some(2)); + assert!(run.stderr.contains("Action: delete the flagged comments")); + assert!(!run.stderr.contains("Deleted")); +} diff --git a/npm/packages/comment-checker/README.md b/npm/packages/comment-checker/README.md index 760726d..6206492 100644 --- a/npm/packages/comment-checker/README.md +++ b/npm/packages/comment-checker/README.md @@ -1,6 +1,6 @@ # @systemfsoftware/claude-code-comment-checker -A Claude Code `PostToolUse` hook that flags unnecessary code comments and states the exact reason each one fails — an alternative to flag-everything linters, gated to F1 ≥ 0.85 on a 60-case, 37-language corpus. It never edits your files and never sends code anywhere. +A Claude Code `PostToolUse` hook that flags unnecessary code comments and states the exact reason each one fails — an alternative to flag-everything linters, gated to F1 ≥ 0.85 on a 60-case, 37-language corpus. Without `--strip` it never edits your files. It never sends code anywhere. ## Install @@ -35,6 +35,6 @@ When comments are flagged it writes the report, with per-comment reasons, to std ## Docs -- Full documentation: comments flagged, comments spared, languages, and `--prompt` configuration — [the project README](https://github.com/systemfsoftware/comment-checker/blob/master/README.md) +- Full documentation: comments flagged, comments spared, languages, plus `--prompt` and `--strip` — [the project README](https://github.com/systemfsoftware/comment-checker/blob/master/README.md) - License: [Apache-2.0](https://github.com/systemfsoftware/comment-checker/blob/master/LICENSE) - Development and contributing: [AGENTS.md](https://github.com/systemfsoftware/comment-checker/blob/master/AGENTS.md) \ No newline at end of file