docreaper
Audit comments and docstrings for prose their own referent already carries. Report every verdict, including every block kept.
Step 1 — Determine scope
- With argument: audit only the specified file, directory, or module.
- Without argument: audit every tracked file of the workspace.
An exclusion in Step 2's list that takes a whole file removes that file here, before the script runs, so an excluded file is neither audited nor reported unreached. The pass covers the extent the bullet above selected, minus the files so removed. A file or block the Step 2 script emits as unreached is part of that extent and is one the pass does not reach. Whether to run again on any part of that extent Step 5 reports as unreached is the user's call.
Step 2 — Compute the blocks and their referents
Run the bundled extractor over the files Step 1 selected, in as many invocations as the command line's length requires:
uv run --script ${CLAUDE_SKILL_DIR}/referent.py <files...>
The first run on a machine fetches grammars into a local cache, so it needs network access. Treat a run that exits nonzero as having produced no output, discarding anything it printed before failing: exit 2 means Step 1's selection needs correcting, and any other failure names its own cause — the first run's grammar fetch failing without network is the recurring one. Resolve the cause and run it again.
The script parses each file, groups its comments and docstrings into blocks, and computes each block's referent from the parse tree. It emits JSON Lines, one object per file; its module docstring is the output contract's full statement — field shapes, the relation vocabulary, the nullity conditions, the referent's span and text semantics — and each field's consumer is:
path, and per block a linespanand itstext— what Step 5's rows cite, what Step 3's classes read, and what Step 6 edits by.- Per block, the
relationthat bound it — recorded by Step 5. - Per block, the
referent— what classes A and C compare against. excluded_lines— reported out of scope by Step 5.unreached— the extent Step 5 reports as not reached, and Step 1's rerun question.
A comment block is what the script emits as one block.
The block is the unit the delete-or-keep verdict is taken on, and the block and referent boundaries both come from the script's output, never from a boundary the executor draws.
The executor names spans for editing of three kinds only — an exclusion span per the list below, a class C phrase, and a class B duplicated proposition inside a larger block — each as a quotation of the block's emitted text; Step 6 states how an approved change is applied, and what Step 5 quotes from a referent is a report citation, not a span for editing.
A proposition is anything the block states that can be true or false.
The referent is the code the block's reader has in front of them; the referent field is its text.
Not in scope. An exclusion takes the whole file when it names a file — Step 1 applies those — the whole block when it names the whole block, and otherwise takes the span it names; the rest of the block stays in scope and is judged on what remains.
- A file's leading interpreter line — and, in Python, its encoding pragma — which the script emits as
excluded_lines: an instruction to the machine, not prose about the referent. - A block that is only a banner separator, a
shellcheck/noqa/clippydirective, or a license header: carries no claim about the referent. Where such a line sits inside a longer block, it is the named span and the block's prose stays in scope. - Commented-out code, a code example inside a comment, and the data columns of an aligned table inside a comment: code or data, not prose. Each is a named span. An exclusion never takes prose that asserts a proposition: where a table row or a directive line carries a gloss beside its data, the gloss stays in scope and only the data is excluded.
- A file whose own text announces it is generated —
@generatedand "Code generated by … DO NOT EDIT" are the recurring marks — or one the user names as generated: regenerated rather than edited. - A file whose own prose is what an agent executes: there is no code beside it for a referent to name. A script's comments describe the code beside them and stay in scope.
README.md, other top-level*.md,docs/**: written for a repository's visitors rather than for a reader of the code beside it.- An article, a paper manuscript, prose whose subject is not an artifact in the repository: the referent is outside the repository.
Step 3 — Run each class
Class A takes the delete-or-keep verdict Step 2 named the unit of. Classes B and C are observations the block's own report row carries, so a block in class A is still reported as a class B site or a class C description when it qualifies.
Class A — the referent's own text carries the whole block
Two conditions, both required:
- The block asserts at least one proposition.
- Every proposition it asserts is one the referent's own text asserts.
A block asserting nothing is not in this class. Delete nothing on the strength of an empty condition — a banner, a stray fragment, or a block whose prose the exclusions above took away has nothing to compare and stays as it is.
The predicate is about text on both sides: what the block asserts, against what the referent asserts. A condition, a consequence, a reason, a distinction and a property are what a referent's text usually leaves to the block; they illustrate where condition 2 tends to fail rather than decide it, so a block asserting something none of the five names can still fail condition 2, and a block asserting one of the five is in this class when the referent's text states that one too. One proposition the referent's text does not assert, anywhere in the block, keeps the whole block, because the block is the unit.
The test is the kind of content, not the strength of an entailment.
Whether a reader could formally deduce the block from the referent is not asked, so no standard of derivation is imported.
A block in the imperative mood asserts what the construct does — // Write a body string to a temp file and echo its path. asserts the write and the echo — so mood is not what decides condition 1.
A block the referent contradicts is kept, with the argument that the referent does not carry it; correcting it is not this sweep's business. Where the script's contract strips a construct's body from the referent, a contradiction visible only in that stripped body is not this sweep's finding either.
A referent in a language that writes almost nothing before a body — a shell function's name() — carries almost nothing, so class A rarely fires on it, and that is the language's property rather than a gap to compensate for.
The delete-or-keep verdict covers the block's in-scope text only, so a directive or an example the exclusions took away survives a deleted block.
Deletable shapes: // Increment the counter by one. inside a body whose only statement is COUNTER.fetch_add(1, Ordering::Relaxed); a docstring restating a #[deprecated] attribute's own message; a field comment restating the field's own type.
Kept shapes, one per illustrated kind: /// # Panics if center >= chain.len() asserts a condition and its consequence; // SAFETY: the pointer is valid because the caller holds the borrow. asserts a reason; // The wrapped construct renders as literal code, so the math silently fails. asserts a consequence the referent does not show; // p is a patch counter, not the day. asserts a distinction; /// The sort is stable. asserts a property.
Class B — another block in the same file states the same proposition
Two or more in-scope blocks in one file state one proposition. Spans Step 2 placed out of scope are not compared, so a license header or a directive cannot put an in-scope block in this class.
This class looks inside one file only.
Choose no survivor: which copy a reader needs is the user's call, and the sweep is not given who will read what.
Class C — an antecedent neither the block nor its referent supplies
A history-dependent expression — a definite description, or a modifier such as pre-fix — whose antecedent is a change, a review round, or a prior state of the code, and which a reader cannot recover from the block or from the block's referent.
Describing the prior state counts as recovering it: the regex before the wildcard case was added, which matched literals only says what that state was, so it is not in this class, while a bare pre-fix is.
The file's own history is not a recovery source: under squash merge a phrase such as "this change" has no antecedent there, so recovery has to come from the block or the referent.
An ordinary expression naming something in the domain — "the buffer", "the day" — is not in this class however its antecedent resolves; what puts an expression here is that recovering it needs the project's change history.
The two repairs available are naming the antecedent and cutting the phrase; which one applies is the user's call.
Step 4 — Confirm before entering a class
Treat every candidate as unconfirmed until read: read the block and its whole referent before entering it in any class. These shapes look like a class and are not:
- A negation blocking a default inference — "
pis a patch counter, not the day", "a definition file, not a procedure". The negation is what stops the reader's default reading. - A silent failure mode. Nothing in the referent shows what does not happen.
- A
SAFETY,Panics, or invariant claim the referent does not encode. - A label naming what a token closes —
#endif // GUARD_NAME,} // namespace slate. The block asserts which construct this token closes, and the line that opens the construct names the construct rather than its closer. - Two blocks sharing a template but not a proposition. Class B needs the same proposition, not the same phrasing.
- A description that looks like the construct's name spelled out but asserts something beyond the referent.
/// Returns the number of items, or zero when the buffer was drained.asserts a condition, so it is kept though its opening clause reads as a restatement.
Step 5 — Report
Report every in-scope block the pass reached, with:
- its
file:line, the relation the script reported, and the referent it named; - its delete-or-keep verdict, and when that is delete, the part of the referent that carries it;
- when that is keep, the argument for it: which proposition the referent does not carry, or that the block asserts none;
- every class B set the block has a site in, with every site in the set;
- every class C expression in the block, with its two repairs.
Report alongside those rows, not inside them:
- every file, block and span placed out of scope — a file by path, an
excluded_linesentry or an excluded span quoted — with the exclusion that applies to it; - any block the referent contradicts, with what contradicts it, so a kept block that is false is distinguishable in the report from a kept block that is true;
- the extent covered, and every file and block the script emitted as unreached, with its discriminant.
Apply nothing without the user's approval, taken per block.
Step 6 — Apply per approval
Apply an approved change by exact-text replacement: the old text is the block's emitted text, the new text is that text with the approved change applied, and nothing outside the block's span is touched.
Where the old text occurs more than once in the file — class B's byte-identical copies are the recurring case — the block's span says which occurrence is meant; extend the old text with the file text adjacent to that span until the match is unique, and replace only there.
An approved delete subsumes every other change approved for the same block; a class B or class C edit applies only to a block that is kept.
Construct the new text by these rules:
- An approved delete drops the block's in-scope text and keeps every excluded span; a newline goes with it exactly when the block's
textcarries that newline, and a blank line the deletion leaves behind stays. - A removal after which the file would no longer parse — deleting a docstring that is a body's only statement is the recurring shape — is not applied: the block stays, and the report notes that its delete is unappliable.
- An approved class B resolution drops each copy the user approves removing; the user's call names the copy a reader keeps.
- An approved class C repair: cutting drops the phrase; naming the antecedent replaces the phrase with wording the user supplied — this sweep authors no words of its own.
- Keep the block's comment or string delimiters in the new text unless the whole block is deleted, so no delimiter is ever half-deleted.
- Every removal leaves the new text a subsequence of the old; the class C naming above is the only insertion.