Skip to content
Open
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 plugins/code-tidying/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "code-tidying",
"version": "0.24.0",
"version": "0.25.0",
"description": "Code tidying and comment hygiene: /code-tidying:tidy proactively hunts a rotated, glob-scoped lane for Beck-style tidyings under a research-backed scope budget and ships one tight PR; /code-tidying:batch-simplify sweeps a time window, a branch, or an entire repository through grouped, dependency-ordered simplification waves with a fix-first deferral contract that resolves deferrals in the same run instead of filing issues; /code-tidying:dissolve-comments enforces self-describing expressive code over a diff or target, widening to the branch diff and then the whole repository when the tree is clean: it deletes zero-information comments, dissolves code-expressible ones into names and structure behind a tests gate (safe mode restricts applied edits to removals, the aggressive dial keeps only exempt surfaces and terse warnings, and strip deletes every comment but the exempt surfaces without rewriting code), and keeps only terse load-bearing comments code cannot express; /code-tidying:audit-comment-residue is a read-only classifier that flags history, plan, conversational, ticket/PR, and origin-note residue in code comments for author-applied deletion; /code-tidying:audit-dead-code is a read-only whole-repo dead-code hunter running four labeled lanes of unequal confidence (knip for TS/JS, vulture for Python, gopls for Go, and a portable grep lane for shell and PowerShell symbols and unreferenced source files), adjudicating every candidate against dynamic-usage evidence into a dead, uncertain, or alive verdict. Project-specific tidy lanes are scaffolded into a tracked .claude/tidy-lanes/ config folder by a re-runnable setup skill.",
"author": {
"name": "Melodic Software",
Expand Down
10 changes: 10 additions & 0 deletions plugins/code-tidying/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes to the `code-tidying` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.25.0] - 2026-09-29

### Added

- **`audit-dead-code` classifies unlisted extensions and extensionless scripts.** The source
classifier reads its extension list from one place, and an extensionless file with a `#!` first
line is classified by its interpreter. An unreferenced `.sol`, `.s` or shebang script is now an
`unreferenced-file` candidate and counts in `Summary coverage`. SKILL.md states how to extend the
list ([#5329](https://github.com/melodic-software/claude-code-plugins/issues/5329)).

## [0.24.0] - 2026-09-29

### Added
Expand Down
8 changes: 4 additions & 4 deletions plugins/code-tidying/skills/audit-dead-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: "Hunt dead code in four lanes (Knip, vulture, gopls, portable grep). Read-only. Use when: 'find dead code', 'audit dead code', 'what is unused in this repo', 'unused exports', 'unreferenced functions', 'orphaned files', 'is anything here still called', 'dead code sweep'. Orphaned files: recognized source extensions only. Not for applying the deletion (/code-tidying:tidy), diff-scoped simplification (/code-tidying:batch-simplify), or comment residue (/code-tidying:audit-comment-residue)."
description: "Hunt dead code in four lanes (Knip, vulture, gopls, portable grep). Read-only. Use when: 'find dead code', 'audit dead code', 'what is unused in this repo', 'unused exports', 'unreferenced functions', 'orphaned files', 'is anything here still called', 'dead code sweep'. Orphaned files: listed source extensions and shebang scripts. Not for applying the deletion (/code-tidying:tidy), diff-scoped simplification (/code-tidying:batch-simplify), or comment residue (/code-tidying:audit-comment-residue)."
argument-hint: "[--max N] [--lane knip|vulture|gopls|grep] [target]"
user-invocable: true
disable-model-invocation: false
Expand Down Expand Up @@ -33,11 +33,11 @@ Every figure in the Measured character column comes from this plugin's own trap
`evals/fixtures/`, as recorded on 2026-08-23 (the JS/TS symbol figures: 2026-09-29). Recheck trigger: a major version bump in any lane's
detector, or a change to the fixture corpus. Re-measure before quoting one to a user.

Orphaned-**file** coverage spans source files with a recognized extension; the extension list is closed. The grep lane emits `unreferenced-file` at tier 2
Orphaned-**file** coverage spans source files with a recognized extension, plus extensionless files with a line-1 shebang. The extension list is `DC_NOLANE_EXTS` in `scripts/lib/dead-code-shapes.sh`; add a lowercase extension there to classify it. An extensionless file is classified by its shebang interpreter: a shell (`sh`, `bash`, `dash`, `zsh`, `ksh`, including `env` forms) joins the shell lane, any other interpreter is a source file with no lane, and no shebang means not source. The grep lane emits `unreferenced-file` at tier 2
when a source file's basename and its repo-relative path (plus its stem, for a language with no
lane) have no literal reference in any other tracked file. That set is shell, PowerShell, Python entry points (a line-1 shebang, a
`__name__` guard, or `__main__.py`), JS/TS that no `package.json` root owns, and source files in languages with no
lane. Files with an unlisted extension (for example `.s`, `.sol`) and extensionless scripts are not classified. Knip still reports unused TS/JS files inside a `package.json` root. A reference in a CI
lane. Knip still reports unused TS/JS files inside a `package.json` root. A reference in a CI
workflow, settings file, manifest, or doc saves the file. A computed path or a glob does not, so
the candidate is **uncertain, not dead**. Rust and .NET stay out of the build-based detectors; an
unreferenced file there is still this candidate, because those builds do not spell every path.
Expand Down Expand Up @@ -194,7 +194,7 @@ Note: uncovered cmd/tool/main.go — no manifest root
Note: uncovered src/main.rs — no lane for the language
```

`Summary total: files-with-findings=` counts files that emitted at least one candidate. `Summary coverage:` counts every in-scope source file: covered by a lane in `ran`, or uncovered. Uncovered reasons are `no lane for the language`, `no manifest root`, `tool not installed`, `lane degraded`, `tool could not parse it`, and `lane not selected`. Extensionless scripts are not classified yet. Markdown, JSON, YAML, and other non-source files are not in that total. When `uncovered` is greater than zero the script lists each file and does not print the clean-result note or the scan-of-nothing note.
`Summary total: files-with-findings=` counts files that emitted at least one candidate. `Summary coverage:` counts every in-scope source file: covered by a lane in `ran`, or uncovered. Uncovered reasons are `no lane for the language`, `no manifest root`, `tool not installed`, `lane degraded`, `tool could not parse it`, and `lane not selected`. The grep lane covers files with no symbol lane, since it checks them for references; `no lane for the language` remains when that lane is not selected. Markdown, JSON, YAML, and other non-source files are not in that total. When `uncovered` is greater than zero the script lists each file and does not print the clean-result note or the scan-of-nothing note.

Present per file: verdict, shape, line, the evidence checked, and for `alive` what saved it.
Close with the lane roster, the candidate count against the cap, and `n dropped by cap`.
Expand Down
5 changes: 3 additions & 2 deletions plugins/code-tidying/skills/audit-dead-code/context/lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ even when some other root ran, unless another lane took it. The grep lane takes
`package.json` root owns, so a full run covers it and only `--lane knip` leaves it listed.
`skipped` stays the missing-or-uninvocable binary state.

Source files with no lane are the extensions `dc_lang_of_path` classifies as `nolane` (Rust, .NET,
JVM, Ruby, C and C++, and the other extensions named there). Docs, JSON, YAML, and other
Source files with no lane are the extensions in `DC_NOLANE_EXTS` (Rust, .NET, JVM, Ruby, C and
C++, and the others listed there) and extensionless files whose shebang names a non-shell
interpreter. The grep lane covers them when it runs. Docs, JSON, YAML, and other
non-source paths are not in the coverage total.

### knip: TS/JS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,12 @@ lane_grep() {
sym_detail='no symbol definition matched the extractor set'
fi
emit_unreferenced_files
dc_account_lane_files ran ${DEF_FILES[@]+"${DEF_FILES[@]}"}
local -a searched_nolane=()
for f in ${NOLANE_FILES[@]+"${NOLANE_FILES[@]}"}; do
[[ $f != *$'\t'* && $f != *$'\n'* ]] || continue
if dc_ref_key_ok "${f##*/}" || dc_ref_key_ok "$f"; then searched_nolane+=("$f"); fi
done
dc_account_lane_files ran ${DEF_FILES[@]+"${DEF_FILES[@]}"} ${searched_nolane[@]+"${searched_nolane[@]}"}
lane_line grep '.' ran "$inspected_n" \
"$sym_detail; $FILE_REF_EMITTED unreferenced-file candidate(s)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,39 @@ assert_equal "a language with no lane still yields one unreferenced file" "1" "$
assert_contains "the unreferenced rust file is a candidate" "$rs_out" "File: src/main.rs"
assert_not_contains "a doc path saves the other rust file" "$rs_out" "File: src/used.rs"

# An extension from DC_NOLANE_EXTS and an extensionless script with a line-1
# shebang are source; an extensionless file with no shebang is not.
XT_REPO="$TEST_TMPDIR/unref-extra"
init_repo "$XT_REPO"
mkdir -p "$XT_REPO/contracts" "$XT_REPO/boot" "$XT_REPO/bin"
printf '%s\n' 'contract A {}' >"$XT_REPO/contracts/tokenvault.sol"
printf '%s\n' 'contract B {}' >"$XT_REPO/contracts/escrowpool.sol"
printf '%s\n' 'nop' >"$XT_REPO/boot/kernelentry.s"
printf '%s\n' 'nop' >"$XT_REPO/boot/bootloader.s"
printf '%s\r\n' '#!/usr/bin/env bash' 'echo run' >"$XT_REPO/bin/runjob"
printf '%s\n' '#! /bin/sh' 'echo sync' >"$XT_REPO/bin/syncdata"
printf '%s\n' 'MIT License' >"$XT_REPO/LICENSE"
printf '%s\n' 'Uses contracts/escrowpool.sol, boot/bootloader.s and bin/syncdata.' >"$XT_REPO/README.md"
stage_repo "$XT_REPO"
xt_out="$(cd "$XT_REPO" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_equal "three unreferenced files, one per kind" "3" "$(count_shape "$xt_out" "Finding shape: unreferenced-file")"
assert_contains "an unreferenced .sol file is a candidate" "$xt_out" "File: contracts/tokenvault.sol"
assert_contains "an unreferenced .s file is a candidate" "$xt_out" "File: boot/kernelentry.s"
assert_contains "an unreferenced extensionless shell script is a candidate" "$xt_out" "File: bin/runjob"
assert_not_contains "a referenced .sol file is not a candidate" "$xt_out" "File: contracts/escrowpool.sol"
assert_not_contains "a referenced .s file is not a candidate" "$xt_out" "File: boot/bootloader.s"
assert_not_contains "a referenced extensionless script is not a candidate" "$xt_out" "File: bin/syncdata"
assert_not_contains "an extensionless file with no shebang is not scanned" "$xt_out" "File: LICENSE"
assert_contains "coverage counts all six source files" "$xt_out" "Summary coverage: covered=6 uncovered=0"

XT_OTHER="$TEST_TMPDIR/unref-other-shebang"
init_repo "$XT_OTHER"
mkdir -p "$XT_OTHER/bin"
printf '%s\n' '#!/usr/bin/env python3' 'print(1)' >"$XT_OTHER/bin/pyjob"
stage_repo "$XT_OTHER"
xo_out="$(cd "$XT_OTHER" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_contains "a non-shell shebang is a source file with no lane" "$xo_out" "File: bin/pyjob"

# A compiled unit is named by its stem, never its filename.
CS_REPO="$TEST_TMPDIR/unref-cs"
init_repo "$CS_REPO"
Expand Down Expand Up @@ -895,11 +928,31 @@ init_repo "$NOLANE_REPO"
mkdir -p "$NOLANE_REPO/src"
printf '%s\n' 'fn main() {}' >"$NOLANE_REPO/src/main.rs"
stage_repo "$NOLANE_REPO"
nolane_out="$(cd "$NOLANE_REPO" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_contains "rust is uncovered because no lane owns it" "$nolane_out" \
nolane_out="$(cd "$NOLANE_REPO" && bash "$SCAN" --lane knip 2>/dev/null)"
assert_contains "rust is uncovered when the grep lane is not selected" "$nolane_out" \
"Note: uncovered src/main.rs — no lane for the language"
assert_contains "nolane coverage is one uncovered file" "$nolane_out" \
"Summary coverage: covered=0 uncovered=1"
nolane_grep_out="$(cd "$NOLANE_REPO" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_contains "the grep lane covers a file with no symbol lane" "$nolane_grep_out" \
"Summary coverage: covered=1 uncovered=0"

# A no-lane file with no searchable key is never searched, so it stays uncovered.
SHORT_REPO="$TEST_TMPDIR/nolane-short"
init_repo "$SHORT_REPO"
printf '%s\n' '#!/usr/bin/python3' 'print(1)' >"$SHORT_REPO/py"
stage_repo "$SHORT_REPO"
short_out="$(cd "$SHORT_REPO" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_contains "a file too short to search is uncovered" "$short_out" \
"Summary coverage: covered=0 uncovered=1"

# An env option's argument is not the interpreter.
ENVOPT_REPO="$TEST_TMPDIR/env-option"
init_repo "$ENVOPT_REPO"
printf '%s\n' '#!/usr/bin/env -S -u PYTHONPATH bash' 'unused_helper() { :; }' >"$ENVOPT_REPO/tool-launcher"
stage_repo "$ENVOPT_REPO"
envopt_out="$(cd "$ENVOPT_REPO" && bash "$SCAN" --lane grep 2>/dev/null)"
assert_contains "env -u ARG bash is a shell script" "$envopt_out" "unused_helper"

# --- 14. Symbol pass for JS/TS that no package.json root owns ---
# The grep lane extracts function, class, const, let and var declarations from those
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,27 +55,61 @@ dc_is_excluded_path() {
return 1
}

# Lowercase extensions of source files with no symbol detector. Add one here,
# space-separated, to classify it.
DC_NOLANE_EXTS=" rs cs fs fsx vb java kt kts scala rb php c h cc cpp cxx hpp hh hxx swift lua ex exs erl hs ml mli dart pl pm zig nim clj cljs groovy vue svelte sql pyi r tf proto m mm s asm sol "

# In-skill fallback glob table. A consumer repo that ships
# .claude/ecosystems/<eco>.yaml has richer globs; this table is the common path
# because most repos do not. `install-hint` is deliberately not consumed — it
# names an ecosystem's lint tools, never a dead-code detector.
#
# `nolane` is a source file this roster has no symbol detector for (Rust, .NET,
# and the other extensions below). The grep lane can still report it as
# `unreferenced-file`. `other` is not source (docs, manifests, markup) and is
# outside the coverage total.
# `nolane` is a source file this roster has no symbol detector for: an extension
# in DC_NOLANE_EXTS (Rust, .NET, and the rest), or an extensionless file whose
# line-1 shebang names an interpreter other than a shell. The grep lane can still
# report it as `unreferenced-file`. An extensionless file with a shell shebang is
# `shell`. `other` is not source (docs, manifests, markup, an extensionless file
# with no shebang) and is outside the coverage total.
dc_lang_of_path() {
local base="${1##*/}" line word interp=''
local -a words
case "${1,,}" in
*.ts | *.tsx | *.mts | *.cts | *.js | *.jsx | *.mjs | *.cjs) printf 'ts' ;;
*.py) printf 'py' ;;
*.go) printf 'go' ;;
*.sh | *.bash) printf 'shell' ;;
*.ps1 | *.psm1) printf 'pwsh' ;;
*.rs | *.cs | *.fs | *.fsx | *.vb | *.java | *.kt | *.kts | *.scala | *.rb | *.php | \
*.c | *.h | *.cc | *.cpp | *.cxx | *.hpp | *.hh | *.hxx | *.swift | *.lua | *.ex | *.exs | \
*.erl | *.hs | *.ml | *.mli | *.dart | *.pl | *.pm | *.zig | *.nim | *.clj | *.cljs | \
*.groovy | *.vue | *.svelte | *.sql | *.pyi | *.r | *.tf | *.proto | *.m | *.mm) printf 'nolane' ;;
*) printf 'other' ;;
*)
if [[ "$base" == *.* ]]; then
base="${base,,}"
if [[ "$DC_NOLANE_EXTS" == *" ${base##*.} "* ]]; then printf 'nolane'; else printf 'other'; fi
elif [[ -f "$1" && -r "$1" ]]; then
{ IFS= read -r line || true; } <"$1"
line="${line//$'\r'/}"
if [[ "$line" == '#!'* ]]; then
read -ra words <<<"${line#'#!'}"
local skip=0
for word in ${words[@]+"${words[@]}"}; do
if ((skip)); then
skip=0
continue
fi
case "$word" in -u | -C | --unset | --chdir) skip=1 ;; *) ;; esac
interp="${word##*/}"
[[ "$interp" == env || "$word" == -* || "$word" == *=* ]] || break
done
Comment on lines +92 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse env option arguments before classifying the interpreter

When a valid shebang uses an env option with a separate argument, such as #!/usr/bin/env -S -u PYTHONPATH bash or -C DIR, this loop skips -u/-C but mistakes the following argument for the interpreter and classifies the script as nolane. GNU env --help documents that -u and -C take arguments and that -S supports multi-argument shebangs. Consequently, a referenced shell script can bypass shell-symbol extraction, contain an unreferenced function, and still produce a clean result with covered=1; consume arguments for these options before selecting the interpreter.

Useful? React with 👍 / 👎.

case "$interp" in
sh | bash | dash | zsh | ksh) printf 'shell' ;;
'' | env) printf 'other' ;;
*) printf 'nolane' ;;
esac
else
printf 'other'
fi
else
printf 'other'
fi
;;
esac
}

Expand Down
Loading