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
5 changes: 5 additions & 0 deletions .changeset/strip-flagged-comments.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Cargo.lock

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

18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
38 changes: 34 additions & 4 deletions crates/comment-checker/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Finding>,
},
}

/// Run the check over the raw hook JSON.
Expand All @@ -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<Finding> {
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<Comment> {
match hook.tool_name.as_str() {
Expand Down
10 changes: 7 additions & 3 deletions crates/comment-checker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
64 changes: 61 additions & 3 deletions crates/comment-checker/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -13,19 +14,76 @@ struct Cli {
/// Replace the default warning message; `{{comments}}` inserts the report.
#[arg(long)]
prompt: Option<String>,
/// 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(&note);
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(&note);
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)
}
Expand Down
68 changes: 65 additions & 3 deletions crates/comment-checker/src/report.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
//! 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)]
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<PositionRole>,
}

#[must_use]
pub fn format_report(flagged: &[Flagged<'_>], file_path: &str, custom_prompt: &str) -> String {
let header = format!(
Expand Down Expand Up @@ -36,15 +46,67 @@ 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 {
custom_prompt.replace("{{comments}}", &report)
}
}

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")
Expand Down
Loading