diff --git a/plugins/code-tidying/.claude-plugin/plugin.json b/plugins/code-tidying/.claude-plugin/plugin.json index 973d46677a..e3a6cbc64d 100644 --- a/plugins/code-tidying/.claude-plugin/plugin.json +++ b/plugins/code-tidying/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/code-tidying/CHANGELOG.md b/plugins/code-tidying/CHANGELOG.md index dd24930d48..8b1088fb7f 100644 --- a/plugins/code-tidying/CHANGELOG.md +++ b/plugins/code-tidying/CHANGELOG.md @@ -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 diff --git a/plugins/code-tidying/skills/audit-dead-code/SKILL.md b/plugins/code-tidying/skills/audit-dead-code/SKILL.md index 5d9a9399bb..6a20efe701 100644 --- a/plugins/code-tidying/skills/audit-dead-code/SKILL.md +++ b/plugins/code-tidying/skills/audit-dead-code/SKILL.md @@ -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 @@ -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. @@ -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`. diff --git a/plugins/code-tidying/skills/audit-dead-code/context/lanes.md b/plugins/code-tidying/skills/audit-dead-code/context/lanes.md index 8a2d035c8b..1aef0d0726 100644 --- a/plugins/code-tidying/skills/audit-dead-code/context/lanes.md +++ b/plugins/code-tidying/skills/audit-dead-code/context/lanes.md @@ -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 diff --git a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh index 58c17a4815..f1d3400ec1 100755 --- a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh +++ b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh @@ -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)" } diff --git a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.test.sh b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.test.sh index e88b82c6c2..7aa67141c9 100755 --- a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.test.sh +++ b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.test.sh @@ -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" @@ -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 diff --git a/plugins/code-tidying/skills/audit-dead-code/scripts/lib/dead-code-shapes.sh b/plugins/code-tidying/skills/audit-dead-code/scripts/lib/dead-code-shapes.sh index 330ce61cb4..5add657dbe 100644 --- a/plugins/code-tidying/skills/audit-dead-code/scripts/lib/dead-code-shapes.sh +++ b/plugins/code-tidying/skills/audit-dead-code/scripts/lib/dead-code-shapes.sh @@ -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/.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 + 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 }